gitshark

Clone repository

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

← Commits

✨ (profile): Add public person profile page and de-duplicate search box

b5b3b70e62cb4aafe7c14c05ac81bca5a9388756 · Michael Hainz · 2026-07-16T12:18:49Z

Changes

12 files changed, +208 -12

MODIFY README.md +4 -2
diff --git a/README.md b/README.md
index 0718444..a12abd3 100644
--- a/README.md
+++ b/README.md
@@ -49,8 +49,10 @@
49 49 - **Search** — a simple case-insensitive substring search over repositories (owner, name,
50 50 description) and people (username, display name), from the header search box or `GET /search`,
51 51 and as JSON at `GET /api/v1/search?q=<term>`. Repository hits obey the same visibility rule as
52 - everywhere else, so a private repo never surfaces to someone who cannot already read it. Guides:
53 - [for users](docs/users/search.md), [for admins](docs/admins/search.md)
52 + everywhere else, so a private repo never surfaces to someone who cannot already read it. Person
53 + hits link to that user's public profile page at `GET /users/{username}` (avatar, display name,
54 + and their visibility-filtered repositories — the person equivalent of the `/orgs/{name}`
55 + organisation profile). Guides: [for users](docs/users/search.md), [for admins](docs/admins/search.md)
54 56 - Single access policy on all paths: owner read/write, collaborators read/write, org roles
55 57 (guest read / member write / owner admin) on org repositories, public world-readable,
56 58 private repositories visible only to whoever holds a read grant
MODIFY docs/users/profile.md +19 -0
diff --git a/docs/users/profile.md b/docs/users/profile.md
index 19f49fb..1c6e768 100644
--- a/docs/users/profile.md
+++ b/docs/users/profile.md
@@ -80,3 +80,22 @@
80 80 Your uploaded picture is served publicly at `/users/<your-username>/avatar` so
81 81 it can be embedded on public pages without requiring the viewer to be logged
82 82 in.
83 +
84 +---
85 +
86 +## Public profile page
87 +
88 +Everyone — including visitors who aren't logged in — has a public profile page
89 +at `/users/<username>`. It's what you land on when you click a person's name
90 +from a [search](search.md) hit.
91 +
92 +It shows:
93 +
94 +- The person's avatar and display name (or their username, if they haven't
95 + set one).
96 +- A list of the repositories they own — filtered to what **you** are allowed
97 + to see: their public repositories, plus their private ones only if you
98 + already have read access (owner, collaborator, or through an organisation).
99 +
100 +An unknown username shows a 404 page. This is the person equivalent of an
101 +[organisation](organisations.md)'s profile at `/orgs/<name>`.
MODIFY docs/users/search.md +6 -3
diff --git a/docs/users/search.md b/docs/users/search.md
index 899588e..e3ac61f 100644
--- a/docs/users/search.md
+++ b/docs/users/search.md
@@ -1,7 +1,9 @@
1 1 # Search
2 2
3 -git-shark has a single search box in the header, on every page. Type a term and
4 -press Enter to land on the results page at `/search?q=<term>`.
3 +git-shark has a single search box in the header, on every page — the results
4 +page has no search box of its own. Type a term and press Enter to land on the
5 +results page at `/search?q=<term>`; the header box stays prefilled with your
6 +term so you can see (and tweak) what you searched for.
5 7
6 8 ## What it searches
7 9
@@ -21,7 +23,8 @@
21 23
22 24 - Repository hits link straight to the repository page and show the same
23 25 name, visibility badge, and description you see in repository lists.
24 -- People hits show the avatar, handle, and display name.
26 +- People hits show the avatar, handle, and display name, and link to that
27 + person's [profile page](profile.md#public-profile-page).
25 28
26 29 You only ever see repositories you are allowed to see: **public repositories,
27 30 plus your own private ones** (and private repositories shared with you as a
MODIFY src/main/java/de/workaround/git/GitRepositoryService.java +5 -0
diff --git a/src/main/java/de/workaround/git/GitRepositoryService.java b/src/main/java/de/workaround/git/GitRepositoryService.java
index 10cee35..9ad0d11 100644
--- a/src/main/java/de/workaround/git/GitRepositoryService.java
+++ b/src/main/java/de/workaround/git/GitRepositoryService.java
@@ -198,6 +198,11 @@
198 198 return repositories.findByOwnerOrg(organisation);
199 199 }
200 200
201 + public List<Repository> listOwnedBy(User owner)
202 + {
203 + return repositories.findByOwnerUser(owner);
204 + }
205 +
201 206 private static void validateName(String name)
202 207 {
203 208 if (name == null || name.isEmpty() || name.equals(".") || name.equals("..")
MODIFY src/main/java/de/workaround/model/Repository.java +3 -0
diff --git a/src/main/java/de/workaround/model/Repository.java b/src/main/java/de/workaround/model/Repository.java
index 6d11ead..b0ace63 100644
--- a/src/main/java/de/workaround/model/Repository.java
+++ b/src/main/java/de/workaround/model/Repository.java
@@ -97,6 +97,9 @@
97 97 @HQL("where ownerOrg = :ownerOrg order by name")
98 98 List<Repository> findByOwnerOrg(Organisation ownerOrg);
99 99
100 + @HQL("where ownerUser = :ownerUser order by name")
101 + List<Repository> findByOwnerUser(User ownerUser);
102 +
100 103 @HQL("select count(r) from Repository r where r.ownerOrg = :ownerOrg")
101 104 long countByOwnerOrg(Organisation ownerOrg);
102 105
MODIFY src/main/java/de/workaround/web/CurrentRequest.java +10 -0
diff --git a/src/main/java/de/workaround/web/CurrentRequest.java b/src/main/java/de/workaround/web/CurrentRequest.java
index 3e14c83..38ad7f4 100644
--- a/src/main/java/de/workaround/web/CurrentRequest.java
+++ b/src/main/java/de/workaround/web/CurrentRequest.java
@@ -26,4 +26,14 @@
26 26 return "/login?redirect=" + URLEncoder.encode(routingContext.request().uri(), StandardCharsets.UTF_8);
27 27 }
28 28
29 + /**
30 + * The current {@code q} query parameter, or an empty string. Used to prefill the single search
31 + * box in the header so the search results page needs no duplicate input of its own.
32 + */
33 + public String searchQuery()
34 + {
35 + String q = routingContext.request().getParam("q");
36 + return q == null ? "" : q;
37 + }
38 +
29 39 }
ADD src/main/java/de/workaround/web/ProfileResource.java +58 -0
diff --git a/src/main/java/de/workaround/web/ProfileResource.java b/src/main/java/de/workaround/web/ProfileResource.java
new file mode 100644
index 0000000..2bfb643
--- /dev/null
+++ b/src/main/java/de/workaround/web/ProfileResource.java
@@ -0,0 +1,58 @@
1 +package de.workaround.web;
2 +
3 +import java.util.List;
4 +
5 +import de.workaround.account.CurrentUser;
6 +import de.workaround.git.AccessPolicy;
7 +import de.workaround.git.GitRepositoryService;
8 +import de.workaround.model.Repository;
9 +import de.workaround.model.User;
10 +import io.quarkus.qute.CheckedTemplate;
11 +import io.quarkus.qute.TemplateInstance;
12 +import jakarta.inject.Inject;
13 +import jakarta.ws.rs.GET;
14 +import jakarta.ws.rs.NotFoundException;
15 +import jakarta.ws.rs.Path;
16 +import jakarta.ws.rs.PathParam;
17 +import jakarta.ws.rs.Produces;
18 +import jakarta.ws.rs.core.MediaType;
19 +
20 +/**
21 + * The public profile page for a person at {@code /users/{username}}: their display name and a
22 + * visibility-filtered list of the repositories they own. Reached by clicking a person hit on the
23 + * search results page. Organisation profiles live under {@code /orgs/{name}}.
24 + */
25 +@Path("/users")
26 +@Produces(MediaType.TEXT_HTML)
27 +public class ProfileResource
28 +{
29 + @CheckedTemplate
30 + static class Templates
31 + {
32 + static native TemplateInstance profile(User profile, List<Repository> repos, User user);
33 + }
34 +
35 + @Inject
36 + CurrentUser currentUser;
37 +
38 + @Inject
39 + GitRepositoryService repositories;
40 +
41 + @Inject
42 + AccessPolicy accessPolicy;
43 +
44 + @Inject
45 + User.Repo users;
46 +
47 + @GET
48 + @Path("{username}")
49 + public TemplateInstance page(@PathParam("username") String username)
50 + {
51 + User profile = users.findByUsername(username).orElseThrow(NotFoundException::new);
52 + User user = currentUser.get();
53 + List<Repository> visible = repositories.listOwnedBy(profile).stream()
54 + .filter(repo -> accessPolicy.canRead(user, repo))
55 + .toList();
56 + return Templates.profile(profile, visible, user);
57 + }
58 +}
ADD src/main/resources/templates/ProfileResource/profile.html +22 -0
diff --git a/src/main/resources/templates/ProfileResource/profile.html b/src/main/resources/templates/ProfileResource/profile.html
new file mode 100644
index 0000000..6ca63f5
--- /dev/null
+++ b/src/main/resources/templates/ProfileResource/profile.html
@@ -0,0 +1,22 @@
1 +{#include layout}
2 +{#title}{profile.username} – git-shark{/title}
3 +<h1>{#avatar user=profile /} {profile.displayName ?: profile.username}</h1>
4 +<p class="muted mono">{profile.username}</p>
5 +<section class="dashboard-section">
6 + <h2>Repositories</h2>
7 + {#if repos.isEmpty()}
8 + <p class="muted empty-state">No repositories yet.</p>
9 + {#else}
10 + <table>
11 + <tr><th>Repository</th><th>Visibility</th><th>Description</th></tr>
12 + {#for repo in repos}
13 + <tr class="row-link">
14 + <td class="cell-link"><a class="mono" href="/repos/{repo.ownerHandle}/{repo.name}">{#repoAvatar repo=repo /} {repo.name}</a></td>
15 + <td><span class="badge badge-{repo.visibility.name().toLowerCase()}">{repo.visibility.name().toLowerCase()}</span></td>
16 + <td class="muted">{repo.description ?: ''}</td>
17 + </tr>
18 + {/for}
19 + </table>
20 + {/if}
21 +</section>
22 +{/include}
MODIFY src/main/resources/templates/SearchResource/results.html +1 -5
diff --git a/src/main/resources/templates/SearchResource/results.html b/src/main/resources/templates/SearchResource/results.html
index cb9dcdb..a492610 100644
--- a/src/main/resources/templates/SearchResource/results.html
+++ b/src/main/resources/templates/SearchResource/results.html
@@ -1,10 +1,6 @@
1 1 {#include layout}
2 2 {#title}Search – git-shark{/title}
3 3 <h1>Search</h1>
4 -<form class="search" method="get" action="/search">
5 - <input type="search" name="q" value="{query}" placeholder="Search repositories and people" aria-label="Search">
6 - <button class="btn btn-primary">Search</button>
7 -</form>
8 4 {#if query.isBlank()}
9 5 <p class="muted">Type a query to search repositories and people.</p>
10 6 {#else}
@@ -29,7 +25,7 @@
29 25 {#else}
30 26 <ul class="person-list">
31 27 {#for person in persons}
32 - <li>{#avatar user=person /} <span class="mono">{person.username}</span>{#if person.displayName} <span class="muted">{person.displayName}</span>{/if}</li>
28 + <li><a href="/users/{person.username}">{#avatar user=person /} <span class="mono">{person.username}</span>{#if person.displayName} <span class="muted">{person.displayName}</span>{/if}</a></li>
33 29 {/for}
34 30 </ul>
35 31 {/if}
MODIFY src/main/resources/templates/layout.html +1 -1
diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html
index 0c99c99..318abff 100644
--- a/src/main/resources/templates/layout.html
+++ b/src/main/resources/templates/layout.html
@@ -12,7 +12,7 @@
12 12 <header class="site">
13 13 <a class="brand" href="/"><svg class="bmark" viewBox="0 0 64 64" width="26" height="26" aria-hidden="true"><rect width="64" height="64" rx="15" fill="#168b85"></rect><path d="M12 42 H52 M24 42 C29 42 31 50 38 50" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"></path><circle cx="16" cy="42" r="3.2" fill="#fff"></circle><circle cx="38" cy="50" r="3.2" fill="#fff"></circle><path d="M26 42 C28 32 32 24 39 14 C40 22 42 33 45 42 Z" fill="#fff"></path></svg><span class="g">git</span><span class="s">shark</span></a>
14 14 <form class="nav-search" method="get" action="/search" role="search">
15 - <input type="search" name="q" placeholder="Search" aria-label="Search repositories and people">
15 + <input type="search" name="q" value="{cdi:currentRequest.searchQuery}" placeholder="Search" aria-label="Search repositories and people">
16 16 </form>
17 17 <nav>
18 18 {#insert nav}
ADD src/test/java/de/workaround/web/ProfileUiTest.java +64 -0
diff --git a/src/test/java/de/workaround/web/ProfileUiTest.java b/src/test/java/de/workaround/web/ProfileUiTest.java
new file mode 100644
index 0000000..fc35817
--- /dev/null
+++ b/src/test/java/de/workaround/web/ProfileUiTest.java
@@ -0,0 +1,64 @@
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.GitRepositoryService;
8 +import de.workaround.model.Repository;
9 +import de.workaround.model.User;
10 +import io.quarkus.test.junit.QuarkusTest;
11 +import jakarta.inject.Inject;
12 +import jakarta.transaction.Transactional;
13 +
14 +import static io.restassured.RestAssured.given;
15 +import static org.hamcrest.CoreMatchers.containsString;
16 +import static org.hamcrest.CoreMatchers.not;
17 +
18 +@QuarkusTest
19 +class ProfileUiTest
20 +{
21 + @Inject
22 + GitRepositoryService service;
23 +
24 + @Inject
25 + User.Repo userRepo;
26 +
27 + @Test
28 + void profilePageListsOwnersVisibleRepos()
29 + {
30 + String tok = "prof" + shortId();
31 + User owner = persistUser("person-" + tok, "Person " + tok);
32 + service.create(owner, "pub-" + tok, Repository.Visibility.PUBLIC, "a public one");
33 + service.create(owner, "sec-" + tok, Repository.Visibility.PRIVATE, null);
34 +
35 + given().when().get("/users/person-" + tok)
36 + .then().statusCode(200)
37 + .body(containsString("Person " + tok))
38 + .body(containsString("/repos/person-" + tok + "/pub-" + tok))
39 + .body(not(containsString("sec-" + tok)));
40 + }
41 +
42 + @Test
43 + void unknownUserReturns404()
44 + {
45 + given().when().get("/users/nope-" + shortId())
46 + .then().statusCode(404);
47 + }
48 +
49 + private static String shortId()
50 + {
51 + return UUID.randomUUID().toString().substring(0, 8);
52 + }
53 +
54 + @Transactional
55 + User persistUser(String name, String displayName)
56 + {
57 + User user = new User();
58 + user.oidcSub = name;
59 + user.username = name;
60 + user.displayName = displayName;
61 + user.persist();
62 + return user;
63 + }
64 +}
MODIFY src/test/java/de/workaround/web/SearchUiTest.java +15 -1
diff --git a/src/test/java/de/workaround/web/SearchUiTest.java b/src/test/java/de/workaround/web/SearchUiTest.java
index fa316dc..26b00e7 100644
--- a/src/test/java/de/workaround/web/SearchUiTest.java
+++ b/src/test/java/de/workaround/web/SearchUiTest.java
@@ -36,7 +36,21 @@
36 36 .then().statusCode(200)
37 37 .body(containsString("/repos/owner-" + tok + "/repo-" + tok))
38 38 .body(not(containsString("priv-" + tok)))
39 - .body(containsString("owner-" + tok));
39 + .body(containsString("owner-" + tok))
40 + // person hits link to the profile page (#11)
41 + .body(containsString("/users/owner-" + tok));
42 + }
43 +
44 + @Test
45 + void resultsPageHasNoOwnSearchInputAndPrefillsHeader()
46 + {
47 + String tok = "dup" + shortId();
48 + given().when().get("/search?q=" + tok)
49 + .then().statusCode(200)
50 + // the page must not carry its own duplicate search form (class="search")
51 + .body(not(containsString("class=\"search\"")))
52 + // the single (header) search box shows what was searched
53 + .body(containsString("value=\"" + tok + "\""));
40 54 }
41 55
42 56 @Test

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog