gitshark

Clone repository

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

← Commits

✨ (api): Start Gitea-compatible /api/v1 migration (auth, version, user)

51979f49148c0326f2c4d6fedca2ad7b8ebc8f01 · Michael Hainz · 2026-07-21T11:06:07Z

Changes

13 files changed, +186 -18

MODIFY README.md +10 -5
diff --git a/README.md b/README.md
index 5490fb8..b98b446 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,9 @@
62 62 (guest read / member write / owner admin) on org repositories, public world-readable,
63 63 private repositories visible only to whoever holds a read grant
64 64 - **JSON REST API** under `/api/v1`, authenticated with the same personal access tokens as
65 - git-over-HTTP (`Authorization: Bearer <token>`), auto-documented via OpenAPI/Swagger UI (see below)
65 + git-over-HTTP (`Authorization: Bearer <token>` or the Gitea-style `Authorization: token <token>`),
66 + auto-documented via OpenAPI/Swagger UI. The contract is **Gitea-compatible**, so Gitea tooling
67 + (Renovate, `tea`) can drive it (see below)
66 68 - **MCP server** at `/mcp` (Streamable HTTP), exposing the same feature set as the REST API as
67 69 MCP tools so an AI client can manage repositories, issues, merge requests, and MR line-comments
68 70 (see below)
@@ -125,10 +127,12 @@
125 127 ## REST API
126 128
127 129 A JSON REST API is served under `/api/v1`, auto-documented via the existing
128 -`quarkus-smallrye-openapi` extension (`GET /q/openapi`, `GET /q/swagger-ui`).
130 +`quarkus-smallrye-openapi` extension (`GET /q/openapi`, `GET /q/swagger-ui`). The surface is being
131 +migrated to a **Gitea-compatible contract** so Gitea tooling (Renovate, `tea`) can consume it
132 +unchanged; `GET /api/v1/version` reports the emulated Gitea version (`GITSHARK_GITEA_API_VERSION`).
129 133
130 -- Authenticated with the **same personal access tokens** used for git-over-HTTP, but sent as
131 - `Authorization: Bearer <token>` (not HTTP Basic)
134 +- Authenticated with the **same personal access tokens** used for git-over-HTTP, sent as either
135 + `Authorization: Bearer <token>` or the Gitea-style `Authorization: token <token>` (not HTTP Basic)
132 136 - Anonymous requests are allowed for public reads only; mutations require a token and write
133 137 access (owner or collaborator; posting a comment only requires read access, deleting a
134 138 repository stays owner-only)
@@ -136,7 +140,8 @@
136 140
137 141 | Method | Path | Description |
138 142 |---|---|---|
139 -| GET | `/api/v1/user` | The token owner (`401` without a valid token) |
143 +| GET | `/api/v1/version` | Emulated Gitea version (anonymous; gates Gitea-client feature use) |
144 +| GET | `/api/v1/user` | The token owner in Gitea user shape (`401` without a valid token) |
140 145 | GET | `/api/v1/repos` | Repositories visible to the caller |
141 146 | POST | `/api/v1/repos` | Create a repository (`400` invalid name, `409` duplicate) |
142 147 | GET | `/api/v1/repos/{owner}/{name}` | Repository detail |
MODIFY docs/admins/getting-started.md +1 -0
diff --git a/docs/admins/getting-started.md b/docs/admins/getting-started.md
index 2effe31..7c9b062 100644
--- a/docs/admins/getting-started.md
+++ b/docs/admins/getting-started.md
@@ -368,6 +368,7 @@
368 368 | `GITSHARK_FEDERATION_USER_RESYNC_INTERVAL` | — | `5m` | Re-scan followed users for new public repos |
369 369 | `GITSHARK_FEDERATION_DEV_ALLOW_INSECURE` | — | `false` | Dev only: allow http/loopback peers |
370 370 | `GITSHARK_ADMIN_HANDLES` | — | — | Comma-separated handles allowed into `/admin/*` (CI runner management); empty means no admins (see [CI runners](ci-runners.md)) |
371 +| `GITSHARK_GITEA_API_VERSION` | — | `1.13.0` | Version string reported by `GET /api/v1/version`. The `/api/v1` surface is Gitea-compatible; Gitea clients (Renovate, `tea`) gate features on this. Kept below `1.14.0` so they only call implemented endpoints — raise it as reviewer/label/status support lands |
371 372
372 373 ### Optional: push mirrors
373 374
MODIFY src/main/java/de/workaround/api/ApiModels.java +34 -2
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
index a942b47..9ebb054 100644
--- a/src/main/java/de/workaround/api/ApiModels.java
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -3,11 +3,14 @@
3 3 import java.time.Instant;
4 4 import java.util.List;
5 5
6 +import com.fasterxml.jackson.annotation.JsonProperty;
7 +
6 8 import de.workaround.model.Issue;
7 9 import de.workaround.model.IssueComment;
8 10 import de.workaround.model.MergeRequest;
9 11 import de.workaround.model.MergeRequestComment;
10 12 import de.workaround.model.Repository;
13 +import de.workaround.model.User;
11 14
12 15 /**
13 16 * JSON request and response shapes for the {@code /api/v1} surface. Response records are projected from
@@ -85,11 +88,40 @@
85 88 }
86 89 }
87 90
88 - public record UserView(String username, String displayName)
91 + /**
92 + * A user in the Gitea contract. Renovate (and other Gitea clients) read {@code login} as the handle and
93 + * {@code full_name}/{@code email} for commit metadata; {@code username} is emitted too as Gitea's historical
94 + * alias for {@code login}. Null display name/email surface as empty strings so consumers never NPE on them.
95 + */
96 + public record UserView(long id, String login, String username, @JsonProperty("full_name") String fullName,
97 + String email)
98 + {
99 + public static UserView of(User user)
100 + {
101 + return new UserView(GiteaIds.of(user.id), user.username, user.username,
102 + user.displayName == null ? "" : user.displayName, user.email == null ? "" : user.email);
103 + }
104 + }
105 +
106 + public record VersionView(String version)
89 107 {
90 108 }
91 109
92 - public record SearchView(List<RepositoryView> repositories, List<UserView> persons)
110 + /**
111 + * A person in a listing where the caller's read access is unknown (search is open to anonymous callers).
112 + * Deliberately omits {@code email}: unlike the self-scoped {@code /api/v1/user}, a search response would
113 + * otherwise disclose every matched user's address to unauthenticated callers.
114 + */
115 + public record PersonView(long id, String login, String username, @JsonProperty("full_name") String fullName)
116 + {
117 + public static PersonView of(User user)
118 + {
119 + return new PersonView(GiteaIds.of(user.id), user.username, user.username,
120 + user.displayName == null ? "" : user.displayName);
121 + }
122 + }
123 +
124 + public record SearchView(List<RepositoryView> repositories, List<PersonView> persons)
93 125 {
94 126 }
95 127
MODIFY src/main/java/de/workaround/api/ApiTokenAuthFilter.java +21 -6
diff --git a/src/main/java/de/workaround/api/ApiTokenAuthFilter.java b/src/main/java/de/workaround/api/ApiTokenAuthFilter.java
index 9ad023d..506b418 100644
--- a/src/main/java/de/workaround/api/ApiTokenAuthFilter.java
+++ b/src/main/java/de/workaround/api/ApiTokenAuthFilter.java
@@ -11,16 +11,19 @@
11 11 import jakarta.ws.rs.ext.Provider;
12 12
13 13 /**
14 - * Authenticates {@code /api/**} requests from an {@code Authorization: Bearer <token>} header, where the
15 - * token is a personal access token (the same secret used for Git-over-HTTP). A valid token populates
16 - * {@link ApiPrincipal}; a present-but-invalid token is rejected with 401. Requests without a token continue
17 - * anonymously — resources decide whether anonymous access is enough (public reads) or not (mutations).
14 + * Authenticates {@code /api/**} requests from an {@code Authorization} header carrying a personal access
15 + * token (the same secret used for Git-over-HTTP), under either the {@code Bearer <token>} or the
16 + * {@code token <token>} scheme — the latter is what Gitea clients (Renovate, tea) send. A valid token
17 + * populates {@link ApiPrincipal}; a present-but-invalid token is rejected with 401. Requests without a token
18 + * continue anonymously — resources decide whether anonymous access is enough (public reads) or not (mutations).
18 19 */
19 20 @Provider
20 21 public class ApiTokenAuthFilter implements ContainerRequestFilter
21 22 {
22 23 private static final String BEARER = "Bearer ";
23 24
25 + private static final String TOKEN = "token ";
26 +
24 27 @Inject
25 28 AccessTokenService tokens;
26 29
@@ -37,11 +40,23 @@
37 40 return;
38 41 }
39 42 String header = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
40 - if (header == null || !header.regionMatches(true, 0, BEARER, 0, BEARER.length()))
43 + if (header == null)
41 44 {
42 45 return;
43 46 }
44 - String token = header.substring(BEARER.length()).trim();
47 + String token;
48 + if (header.regionMatches(true, 0, BEARER, 0, BEARER.length()))
49 + {
50 + token = header.substring(BEARER.length()).trim();
51 + }
52 + else if (header.regionMatches(true, 0, TOKEN, 0, TOKEN.length()))
53 + {
54 + token = header.substring(TOKEN.length()).trim();
55 + }
56 + else
57 + {
58 + return;
59 + }
45 60 tokens.authenticate(token)
46 61 .ifPresentOrElse(principal::set, () -> {
47 62 throw new NotAuthorizedException("Bearer realm=\"git-shark-api\"");
ADD src/main/java/de/workaround/api/GiteaIds.java +21 -0
diff --git a/src/main/java/de/workaround/api/GiteaIds.java b/src/main/java/de/workaround/api/GiteaIds.java
new file mode 100644
index 0000000..1f99504
--- /dev/null
+++ b/src/main/java/de/workaround/api/GiteaIds.java
@@ -0,0 +1,21 @@
1 +package de.workaround.api;
2 +
3 +import java.util.UUID;
4 +
5 +/**
6 + * Surrogate numeric ids for the Gitea-compatible {@code /api/v1} contract. Gitea addresses entities by an
7 + * int64 {@code id}; git-shark keys everything by {@link UUID}. This folds a UUID into a stable, non-negative
8 + * {@code long} so the wire always carries the same {@code id} for a given entity. The mapping is one-way and
9 + * lossy — it is a display surrogate only; git-shark never looks an entity up by it (owner/name/number do that).
10 + */
11 +final class GiteaIds
12 +{
13 + private GiteaIds()
14 + {
15 + }
16 +
17 + static long of(UUID id)
18 + {
19 + return (id.getMostSignificantBits() ^ id.getLeastSignificantBits()) & Long.MAX_VALUE;
20 + }
21 +}
MODIFY src/main/java/de/workaround/api/SearchApiResource.java +1 -1
diff --git a/src/main/java/de/workaround/api/SearchApiResource.java b/src/main/java/de/workaround/api/SearchApiResource.java
index ea9613c..51373f0 100644
--- a/src/main/java/de/workaround/api/SearchApiResource.java
+++ b/src/main/java/de/workaround/api/SearchApiResource.java
@@ -30,6 +30,6 @@
30 30 SearchResults results = search.search(principal.orNull(), q);
31 31 return new ApiModels.SearchView(
32 32 results.repositories().stream().map(ApiModels.RepositoryView::of).toList(),
33 - results.persons().stream().map(user -> new ApiModels.UserView(user.username, user.displayName)).toList());
33 + results.persons().stream().map(ApiModels.PersonView::of).toList());
34 34 }
35 35 }
MODIFY src/main/java/de/workaround/api/UserApiResource.java +1 -1
diff --git a/src/main/java/de/workaround/api/UserApiResource.java b/src/main/java/de/workaround/api/UserApiResource.java
index 39d6005..628c951 100644
--- a/src/main/java/de/workaround/api/UserApiResource.java
+++ b/src/main/java/de/workaround/api/UserApiResource.java
@@ -22,6 +22,6 @@
22 22 public ApiModels.UserView current()
23 23 {
24 24 User user = principal.require();
25 - return new ApiModels.UserView(user.username, user.displayName);
25 + return ApiModels.UserView.of(user);
26 26 }
27 27 }
ADD src/main/java/de/workaround/api/VersionApiResource.java +28 -0
diff --git a/src/main/java/de/workaround/api/VersionApiResource.java b/src/main/java/de/workaround/api/VersionApiResource.java
new file mode 100644
index 0000000..ce3465f
--- /dev/null
+++ b/src/main/java/de/workaround/api/VersionApiResource.java
@@ -0,0 +1,28 @@
1 +package de.workaround.api;
2 +
3 +import org.eclipse.microprofile.config.inject.ConfigProperty;
4 +
5 +import jakarta.ws.rs.GET;
6 +import jakarta.ws.rs.Path;
7 +import jakarta.ws.rs.Produces;
8 +import jakarta.ws.rs.core.MediaType;
9 +
10 +/**
11 + * The Gitea {@code GET /api/v1/version} probe. Gitea clients call it first to gate which features they use,
12 + * so the reported string must match what git-shark actually implements: it is kept deliberately low (below
13 + * 1.14.0) so Renovate does not call the reviewer/label/branch-protection endpoints that are not wired yet.
14 + * Raise {@code gitshark.gitea-api.version} as those capabilities land. Open to anonymous callers.
15 + */
16 +@Path("/api/v1/version")
17 +@Produces(MediaType.APPLICATION_JSON)
18 +public class VersionApiResource
19 +{
20 + @ConfigProperty(name = "gitshark.gitea-api.version")
21 + String version;
22 +
23 + @GET
24 + public ApiModels.VersionView version()
25 + {
26 + return new ApiModels.VersionView(version);
27 + }
28 +}
MODIFY src/main/java/de/workaround/mcp/UserTools.java +1 -1
diff --git a/src/main/java/de/workaround/mcp/UserTools.java b/src/main/java/de/workaround/mcp/UserTools.java
index ab0967a..4b62d99 100644
--- a/src/main/java/de/workaround/mcp/UserTools.java
+++ b/src/main/java/de/workaround/mcp/UserTools.java
@@ -20,6 +20,6 @@
20 20 public ApiModels.UserView currentUser()
21 21 {
22 22 User user = principal.require();
23 - return new ApiModels.UserView(user.username, user.displayName);
23 + return ApiModels.UserView.of(user);
24 24 }
25 25 }
MODIFY src/main/resources/application.properties +4 -0
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 9988859..66a3815 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -84,6 +84,10 @@
84 84 gitshark.admin.handles=${GITSHARK_ADMIN_HANDLES:}
85 85 %dev,test.gitshark.admin.handles=alice
86 86
87 +# Gitea-compatible /api/v1: version reported by GET /api/v1/version. Kept below 1.14.0 so Gitea clients
88 +# (Renovate, tea) only call endpoints git-shark implements; raise as reviewer/label/status support lands.
89 +gitshark.gitea-api.version=${GITSHARK_GITEA_API_VERSION:1.13.0}
90 +
87 91 # Native image: JGit holds static Random/lazy state that must not be build-time initialized
88 92 quarkus.native.additional-build-args=--initialize-at-run-time=org.eclipse.jgit,--initialize-at-run-time=org.apache.sshd.common.random,--initialize-at-run-time=org.apache.sshd.common.util.security.bouncycastle.BouncyCastleEncryptedPrivateKeyInfoDecryptor
89 93 # Register BouncyCastle at build time: SSHD's runtime registrar (Security.addProvider via reflection)
MODIFY src/test/java/de/workaround/api/SearchApiTest.java +25 -0
diff --git a/src/test/java/de/workaround/api/SearchApiTest.java b/src/test/java/de/workaround/api/SearchApiTest.java
index 0575f5b..9a06da4 100644
--- a/src/test/java/de/workaround/api/SearchApiTest.java
+++ b/src/test/java/de/workaround/api/SearchApiTest.java
@@ -13,6 +13,7 @@
13 13 import jakarta.transaction.Transactional;
14 14
15 15 import static io.restassured.RestAssured.given;
16 +import static org.hamcrest.CoreMatchers.containsString;
16 17 import static org.hamcrest.CoreMatchers.hasItem;
17 18 import static org.hamcrest.CoreMatchers.not;
18 19
@@ -41,6 +42,19 @@
41 42 }
42 43
43 44 @Test
45 + void anonymousSearchDoesNotLeakPersonEmail()
46 + {
47 + String tok = "apimail" + shortId();
48 + String email = tok + "@secret.example";
49 + persistUserWithEmail("owner-" + tok, email);
50 +
51 + given().when().get("/api/v1/search?q=" + tok)
52 + .then().statusCode(200)
53 + .body("persons.username", hasItem("owner-" + tok))
54 + .body(not(containsString(email)));
55 + }
56 +
57 + @Test
44 58 void blankQueryReturnsEmptyArrays()
45 59 {
46 60 given().when().get("/api/v1/search?q=")
@@ -63,4 +77,15 @@
63 77 user.persist();
64 78 return user;
65 79 }
80 +
81 + @Transactional
82 + User persistUserWithEmail(String name, String email)
83 + {
84 + User user = new User();
85 + user.oidcSub = name;
86 + user.username = name;
87 + user.email = email;
88 + user.persist();
89 + return user;
90 + }
66 91 }
MODIFY src/test/java/de/workaround/api/UserApiTest.java +19 -2
diff --git a/src/test/java/de/workaround/api/UserApiTest.java b/src/test/java/de/workaround/api/UserApiTest.java
index 31dcb89..17e9654 100644
--- a/src/test/java/de/workaround/api/UserApiTest.java
+++ b/src/test/java/de/workaround/api/UserApiTest.java
@@ -10,6 +10,7 @@
10 10
11 11 import static io.restassured.RestAssured.given;
12 12 import static org.hamcrest.CoreMatchers.equalTo;
13 +import static org.hamcrest.CoreMatchers.notNullValue;
13 14
14 15 @QuarkusTest
15 16 class UserApiTest
@@ -21,7 +22,7 @@
21 22 User.Repo userRepo;
22 23
23 24 @Test
24 - void currentUserReturnsTheTokenOwner()
25 + void currentUserReturnsTheTokenOwnerInGiteaShape()
25 26 {
26 27 User user = persistUser("api-me");
27 28 String token = tokenService.create(user, "api-test").plaintext();
@@ -29,7 +30,23 @@
29 30 given().header("Authorization", "Bearer " + token)
30 31 .when().get("/api/v1/user")
31 32 .then().statusCode(200)
32 - .body("username", equalTo("api-me"));
33 + .body("login", equalTo("api-me"))
34 + .body("username", equalTo("api-me"))
35 + .body("id", notNullValue())
36 + .body("full_name", notNullValue())
37 + .body("email", notNullValue());
38 + }
39 +
40 + @Test
41 + void tokenSchemeAlsoAuthenticates()
42 + {
43 + User user = persistUser("api-token-scheme");
44 + String token = tokenService.create(user, "api-test").plaintext();
45 +
46 + given().header("Authorization", "token " + token)
47 + .when().get("/api/v1/user")
48 + .then().statusCode(200)
49 + .body("login", equalTo("api-token-scheme"));
33 50 }
34 51
35 52 @Test
ADD src/test/java/de/workaround/api/VersionApiTest.java +20 -0
diff --git a/src/test/java/de/workaround/api/VersionApiTest.java b/src/test/java/de/workaround/api/VersionApiTest.java
new file mode 100644
index 0000000..fd62246
--- /dev/null
+++ b/src/test/java/de/workaround/api/VersionApiTest.java
@@ -0,0 +1,20 @@
1 +package de.workaround.api;
2 +
3 +import org.junit.jupiter.api.Test;
4 +
5 +import io.quarkus.test.junit.QuarkusTest;
6 +
7 +import static io.restassured.RestAssured.given;
8 +import static org.hamcrest.CoreMatchers.notNullValue;
9 +
10 +@QuarkusTest
11 +class VersionApiTest
12 +{
13 + @Test
14 + void versionIsReadableAnonymously()
15 + {
16 + given().when().get("/api/v1/version")
17 + .then().statusCode(200)
18 + .body("version", notNullValue());
19 + }
20 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog