✨ (api): Start Gitea-compatible /api/v1 migration (auth, version, user)
Changes
13 files changed, +186 -18
MODIFY
README.md
+10 -5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+}