gitshark

Clone repository

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

← Commits

✨ (web): Render repository README on the overview page

af93acb7ee7cbbc4e47aee082699879fb04db520 · Michael Hainz · 2026-07-08T09:28:03Z

Changes

6 files changed, +221 -3

MODIFY README.md +1 -1
diff --git a/README.md b/README.md
index a540ff4..7c1f095 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
14 14 - push and private read authenticate with **personal access tokens** (HTTP Basic password)
15 15 - Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
16 16 - public-key authentication only; keys managed per user in the UI
17 -- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Repository pages share Files / Commits / Branches / Issues / Merge requests tab navigation that preserves the selected ref, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
17 +- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), a rendered README (commonmark-java, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Repository pages share Files / Commits / Branches / Issues / Merge requests tab navigation that preserves the selected ref, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
18 18 - Per-repository issues: title, optional description, per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New issue" page
19 19 - Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
20 20 - Issues auto-close from pushed commit messages, GitHub-style (`close(s|d)`/`fix(es|ed)`/`resolve(s|d)` + `#<number>`, e.g. `fixes #12`), over both HTTP and SSH pushes
MODIFY pom.xml +6 -0
diff --git a/pom.xml b/pom.xml
index 143562f..c93b49e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,6 +18,7 @@
18 18 <jgit.version>7.3.0.202506031305-r</jgit.version>
19 19 <sshd.version>2.18.0</sshd.version>
20 20 <bouncycastle.version>1.84</bouncycastle.version>
21 + <commonmark.version>0.24.0</commonmark.version>
21 22 <skipITs>true</skipITs>
22 23 <surefire-plugin.version>3.5.6</surefire-plugin.version>
23 24 </properties>
@@ -78,6 +79,11 @@
78 79 <version>${jgit.version}</version>
79 80 </dependency>
80 81 <dependency>
82 + <groupId>org.commonmark</groupId>
83 + <artifactId>commonmark</artifactId>
84 + <version>${commonmark.version}</version>
85 + </dependency>
86 + <dependency>
81 87 <groupId>org.apache.sshd</groupId>
82 88 <artifactId>sshd-core</artifactId>
83 89 <version>${sshd.version}</version>
MODIFY src/main/java/de/workaround/web/RepositoryResource.java +43 -2
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index 1422ec5..f981b0b 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -37,6 +37,8 @@
37 37 import jakarta.ws.rs.core.MediaType;
38 38 import jakarta.ws.rs.core.Response;
39 39 import jakarta.ws.rs.core.UriInfo;
40 +import org.commonmark.parser.Parser;
41 +import org.commonmark.renderer.html.HtmlRenderer;
40 42 import org.eclipse.microprofile.config.inject.ConfigProperty;
41 43
42 44 @jakarta.ws.rs.Path("/repos/{owner}/{name}")
@@ -49,7 +51,8 @@
49 51 static native TemplateInstance overview(Repository repo, boolean owner, boolean empty, String defaultBranch,
50 52 List<GitBrowseService.TreeEntry> entries, String httpUrl, String sshUrl, boolean loggedIn,
51 53 boolean pinned, GitBrowseService.CommitInfo latestCommit, String latestCommitAge, int commitCount,
52 - int branchCount, int tagCount, long openIssueCount, long openMrCount);
54 + int branchCount, int tagCount, long openIssueCount, long openMrCount, String readmeName,
55 + String readmeHtml);
53 56
54 57 static native TemplateInstance tree(Repository repo, String ref, String path,
55 58 List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs, String activeTab);
@@ -116,9 +119,47 @@
116 119 int tagCount = browse.tags(path).size();
117 120 long openIssueCount = issueService.countOpen(repo);
118 121 long openMrCount = mergeRequestService.countOpen(repo);
122 + GitBrowseService.TreeEntry readmeEntry = findReadme(entries);
123 + String readmeName = readmeEntry == null ? null : readmeEntry.name();
124 + String readmeHtml = readmeEntry == null ? null : browse.blob(path, defaultBranch, readmeEntry.path())
125 + .filter(blob -> !blob.binary())
126 + .map(blob -> renderMarkdown(new String(blob.content(), StandardCharsets.UTF_8)))
127 + .orElse(null);
119 128 return Templates.overview(repo, isOwner, empty, defaultBranch, entries, httpUrl(repo), sshUrl(repo),
120 129 loggedIn, pinned, latestCommit, latestCommitAge, commitCount, branchCount, tagCount, openIssueCount,
121 - openMrCount);
130 + openMrCount, readmeName, readmeHtml);
131 + }
132 +
133 + // README file names the overview looks for, in order of preference (matched case-insensitively).
134 + private static final List<String> README_NAMES = List.of("readme.md", "readme.markdown", "readme", "readme.txt");
135 +
136 + private static final Parser MARKDOWN_PARSER = Parser.builder().build();
137 +
138 + // escapeHtml + sanitizeUrls: README content is untrusted user input rendered into our page,
139 + // so raw HTML blocks are escaped and javascript:/data: link targets are stripped.
140 + private static final HtmlRenderer MARKDOWN_RENDERER = HtmlRenderer.builder()
141 + .escapeHtml(true)
142 + .sanitizeUrls(true)
143 + .build();
144 +
145 + private static GitBrowseService.TreeEntry findReadme(List<GitBrowseService.TreeEntry> entries)
146 + {
147 + for (String candidate : README_NAMES)
148 + {
149 + for (GitBrowseService.TreeEntry entry : entries)
150 + {
151 + if (!entry.directory() && entry.name().toLowerCase(Locale.ROOT).equals(candidate))
152 + {
153 + return entry;
154 + }
155 + }
156 + }
157 + return null;
158 + }
159 +
160 + static String renderMarkdown(String markdown)
161 + {
162 + return MARKDOWN_RENDERER.render(MARKDOWN_PARSER.parse(markdown));
122 163 }
123 164
124 165 private static String relativeAge(Instant when)
MODIFY src/main/resources/META-INF/resources/shark.css +106 -0
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 1542788..bb991d5 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -1629,6 +1629,112 @@
1629 1629 color: var(--accent-deep);
1630 1630 }
1631 1631
1632 +/* rendered README on the repository overview */
1633 +
1634 +.readme {
1635 + margin-top: var(--s5);
1636 +}
1637 +
1638 +.readme-head {
1639 + display: flex;
1640 + align-items: center;
1641 + gap: 11px;
1642 + padding: 12px 18px;
1643 + border-bottom: 1px solid var(--border-soft);
1644 + font: 500 14px/1 var(--mono);
1645 + color: var(--ink);
1646 +}
1647 +
1648 +.readme-head .g {
1649 + width: 18px;
1650 + text-align: center;
1651 + font-size: 15px;
1652 + color: var(--faint);
1653 + flex: none;
1654 +}
1655 +
1656 +.readme-body {
1657 + padding: var(--s5) 18px;
1658 + font-size: 14px;
1659 + line-height: 1.65;
1660 + overflow-wrap: break-word;
1661 +}
1662 +
1663 +.readme-body > :first-child {
1664 + margin-top: 0;
1665 +}
1666 +
1667 +.readme-body > :last-child {
1668 + margin-bottom: 0;
1669 +}
1670 +
1671 +.readme-body h1,
1672 +.readme-body h2,
1673 +.readme-body h3,
1674 +.readme-body h4 {
1675 + margin: 1.2em 0 .5em;
1676 + line-height: 1.3;
1677 +}
1678 +
1679 +.readme-body h1 {
1680 + font-size: 22px;
1681 + padding-bottom: .3em;
1682 + border-bottom: 1px solid var(--border-soft);
1683 +}
1684 +
1685 +.readme-body h2 {
1686 + font-size: 18px;
1687 + padding-bottom: .3em;
1688 + border-bottom: 1px solid var(--border-soft);
1689 +}
1690 +
1691 +.readme-body h3 {
1692 + font-size: 15px;
1693 +}
1694 +
1695 +.readme-body p,
1696 +.readme-body ul,
1697 +.readme-body ol,
1698 +.readme-body blockquote {
1699 + margin: 0 0 .9em;
1700 +}
1701 +
1702 +.readme-body ul,
1703 +.readme-body ol {
1704 + padding-left: 1.6em;
1705 +}
1706 +
1707 +.readme-body code {
1708 + font: 500 12.5px/1.5 var(--mono);
1709 + background: var(--accent-soft);
1710 + padding: 2px 5px;
1711 + border-radius: 4px;
1712 +}
1713 +
1714 +.readme-body pre {
1715 + background: var(--canvas);
1716 + border: 1px solid var(--border-soft);
1717 + border-radius: var(--radius);
1718 + padding: var(--s4);
1719 + overflow-x: auto;
1720 + margin: 0 0 .9em;
1721 +}
1722 +
1723 +.readme-body pre code {
1724 + background: none;
1725 + padding: 0;
1726 +}
1727 +
1728 +.readme-body blockquote {
1729 + border-left: 3px solid var(--border-strong);
1730 + padding-left: var(--s4);
1731 + color: var(--muted);
1732 +}
1733 +
1734 +.readme-body img {
1735 + max-width: 100%;
1736 +}
1737 +
1632 1738 .danger-zone {
1633 1739 margin-top: var(--s6);
1634 1740 }
MODIFY src/main/resources/templates/RepositoryResource/overview.html +7 -0
diff --git a/src/main/resources/templates/RepositoryResource/overview.html b/src/main/resources/templates/RepositoryResource/overview.html
index 6b52b58..834c981 100644
--- a/src/main/resources/templates/RepositoryResource/overview.html
+++ b/src/main/resources/templates/RepositoryResource/overview.html
@@ -68,6 +68,13 @@
68 68 {/for}
69 69 </div>
70 70 </div>
71 +
72 + {#if readmeHtml}
73 + <div class="panel readme">
74 + <div class="readme-head"><span class="g">≡</span> {readmeName}</div>
75 + <div class="readme-body">{readmeHtml.raw}</div>
76 + </div>
77 + {/if}
71 78 {/if}
72 79
73 80 {#if owner}
MODIFY src/test/java/de/workaround/web/WebUiTest.java +58 -0
diff --git a/src/test/java/de/workaround/web/WebUiTest.java b/src/test/java/de/workaround/web/WebUiTest.java
index ecd590e..f81093a 100644
--- a/src/test/java/de/workaround/web/WebUiTest.java
+++ b/src/test/java/de/workaround/web/WebUiTest.java
@@ -387,6 +387,64 @@
387 387 }
388 388
389 389 @Test
390 + void repositoryOverviewRendersReadme() throws Exception
391 + {
392 + User owner = persistUser("ui-rita-" + unique());
393 + Repository repo = service.create(owner, "withreadme", Repository.Visibility.PUBLIC, null);
394 + GitTestSeeder.seed(service.repositoryPath(repo), Map.of(
395 + "README.md", "# Hello Shark\n\nSome *emphasis* here.\n".getBytes(StandardCharsets.UTF_8),
396 + "a.txt", "a\n".getBytes(StandardCharsets.UTF_8)));
397 +
398 + given().when().get("/repos/" + owner.username + "/withreadme")
399 + .then().statusCode(200)
400 + .body(containsString("readme-body"))
401 + .body(containsString("README.md"))
402 + .body(containsString("<h1>Hello Shark</h1>"))
403 + .body(containsString("<em>emphasis</em>"));
404 + }
405 +
406 + @Test
407 + void repositoryOverviewFindsReadmeCaseInsensitively() throws Exception
408 + {
409 + User owner = persistUser("ui-sven-" + unique());
410 + Repository repo = service.create(owner, "lowerreadme", Repository.Visibility.PUBLIC, null);
411 + GitTestSeeder.seed(service.repositoryPath(repo), Map.of(
412 + "readme.md", "# lower heading\n".getBytes(StandardCharsets.UTF_8)));
413 +
414 + given().when().get("/repos/" + owner.username + "/lowerreadme")
415 + .then().statusCode(200)
416 + .body(containsString("<h1>lower heading</h1>"));
417 + }
418 +
419 + @Test
420 + void readmeEscapesRawHtml() throws Exception
421 + {
422 + User owner = persistUser("ui-tina-" + unique());
423 + Repository repo = service.create(owner, "evilreadme", Repository.Visibility.PUBLIC, null);
424 + GitTestSeeder.seed(service.repositoryPath(repo), Map.of(
425 + "README.md", "# safe\n\n<script>alert('xss')</script>\n".getBytes(StandardCharsets.UTF_8)));
426 +
427 + given().when().get("/repos/" + owner.username + "/evilreadme")
428 + .then().statusCode(200)
429 + .body(containsString("<h1>safe</h1>"))
430 + .body(not(containsString("<script>alert")))
431 + .body(containsString("&lt;script&gt;"));
432 + }
433 +
434 + @Test
435 + void repositoryOverviewWithoutReadmeShowsNoReadmePanel() throws Exception
436 + {
437 + User owner = persistUser("ui-uwe-" + unique());
438 + Repository repo = service.create(owner, "noreadme", Repository.Visibility.PUBLIC, null);
439 + GitTestSeeder.seed(service.repositoryPath(repo),
440 + Map.of("a.txt", "a\n".getBytes(StandardCharsets.UTF_8)));
441 +
442 + given().when().get("/repos/" + owner.username + "/noreadme")
443 + .then().statusCode(200)
444 + .body(not(containsString("readme-body")));
445 + }
446 +
447 + @Test
390 448 void repositoryOverviewClonesViaDialogNotHeaderPill() throws Exception
391 449 {
392 450 User owner = persistUser("ui-jane-" + unique());

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog