Skip to content

Commit 3eb88d2

Browse files
Added AProxy
1 parent 0e6698b commit 3eb88d2

8 files changed

Lines changed: 510 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ the passwords the same. If you keep the password blank, a random password is gen
3939
"auth.keycloak.claim.id": "",
4040
"auth.keycloak.claim.email": "",
4141
"user.allowSignUp": true,
42-
"user.allowedDomains": "*"
42+
"user.allowedDomains": "*",
43+
"ai.token": "test-ai-token",
44+
"ai.allowed.urls": "http://localhost"
4345
}
4446
```
4547

‎matc.conf‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,7 @@
1515
"admin": "admin@barista.com",
1616
"jwt.password": "Test",
1717
"user.allowSignUp": true,
18-
"user.allowedDomains": "*"
18+
"user.allowedDomains": "*",
19+
"ai.token": "test-ai-token",
20+
"ai.allowed.urls": "http://localhost"
1921
}

‎src/main/java/com/qux/MATC.java‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939

4040
public class MATC extends AbstractVerticle {
4141

42-
public static final String VERSION = "5.1.1";
42+
public static final String VERSION = "5.1.17";
4343

4444
private MongoClient client;
4545

@@ -91,6 +91,7 @@ public void start() {
9191
initLibrary(router);
9292
initBus();
9393
initAIProxy(router);
94+
initAITokenProxy(router, config);
9495

9596

9697
HttpServerOptions options = new HttpServerOptions()
@@ -281,8 +282,16 @@ private void initAIProxy (Router router) {
281282
OpenAIProxyRest proxy = new OpenAIProxyRest(this.tokenService, "api.openai.com");
282283
router.route(HttpMethod.POST, "/ai/openai.json").handler(proxy::forward);
283284
}
284-
285-
285+
286+
private void initAITokenProxy (Router router, JsonObject config) {
287+
logger.info("initAITokenProxy() > enter");
288+
String token = config.getString("ai.token", "");
289+
String allowedUrls = config.getString("ai.allowed.urls", "");
290+
AIProxyREST proxy = new AIProxyREST(tokenService, vertx, client, token, allowedUrls);
291+
router.route("/rest/ai-proxy").handler(proxy::proxy);
292+
router.route("/rest/ai-proxy/*").handler(proxy::proxy);
293+
}
294+
286295
private void initTestRest(Router router) {
287296

288297
TestSettingsRest rest = new TestSettingsRest(this.tokenService, client, TestSetting.class, "testID");
@@ -439,6 +448,8 @@ private void initUserRest(JsonObject config, Router router) {
439448
router.route(HttpMethod.GET, "/rest/user/notification/last.json").handler(user::getNotificationView);
440449
router.route(HttpMethod.POST, "/rest/user/privacy/update.json").handler(user::updatePrivacy);
441450

451+
router.route(HttpMethod.POST, "/rest/user/:id/tos.json").handler(user.updateTOS());
452+
442453
router.route(HttpMethod.POST, "/rest/user/external").handler(user::createExternalIfNotExists);
443454

444455
PasswordRest pass = new PasswordRest(this.tokenService, client);

‎src/main/java/com/qux/model/User.java‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.qux.model;
22

33
import java.io.Serializable;
4+
import java.util.Arrays;
5+
import java.util.Collections;
6+
import java.util.List;
47

58
import com.fasterxml.jackson.annotation.JsonIgnore;
69

@@ -23,7 +26,15 @@ public class User extends Model implements Serializable {
2326
public static final String STATUS_RETIRED = "retired";
2427

2528
public static final User GUEST_USER = new User("-1", "Guest", "Guest", "guest@quant-ux.com", GUEST );
26-
29+
30+
public static final List<String> FIELD_TOS = Collections.unmodifiableList(Arrays.asList("acceptedAI", "acceptedTOS", "acceptedPrivacy", "acceptedGDPR"));
31+
32+
public static final String FIELD_AI_USAGE = "aiUsage";
33+
34+
public static final String FIELD_AI_USAGE_TOTAL = "aiUsageTotal";
35+
36+
public static final int AI_USAGE_LIMIT = 100000000;
37+
2738

2839
private String name;
2940

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
package com.qux.rest;
2+
3+
4+
import com.qux.auth.ITokenService;
5+
import com.qux.model.User;
6+
import com.qux.util.rest.MongoREST;
7+
import io.vertx.core.Vertx;
8+
import io.vertx.core.buffer.Buffer;
9+
import io.vertx.core.http.*;
10+
import io.vertx.core.json.JsonObject;
11+
import io.vertx.core.streams.Pump;
12+
import io.vertx.ext.mongo.MongoClient;
13+
import io.vertx.ext.web.RoutingContext;
14+
15+
import java.net.URI;
16+
import java.util.*;
17+
18+
/**
19+
* Proxy for AI requests. Unlike {@link ProxyREST}, which forwards on
20+
* behalf of an invitation hash and per-app secrets, this proxy is used
21+
* directly by logged in users and is authorized against a shared server
22+
* side token, with a per user usage quota.
23+
*/
24+
public class AIProxyREST extends MongoREST {
25+
26+
public static final String HEADER_URL = "x-qux-url";
27+
28+
public static final String HEADER_AUTHORIZATION = "Authorization";
29+
30+
public static final String BEARER_PREFIX = "Bearer ";
31+
32+
33+
private static final Set<String> EXCLUDED_HEADERS = new HashSet<>(Arrays.asList(
34+
"host", "connection", "content-length", "transfer-encoding", "accept-encoding",
35+
"upgrade", "keep-alive", "origin", "referer", "cookie",
36+
HEADER_URL, "authorization"
37+
));
38+
39+
private static final Set<String> EXCLUDED_RESPONSE_HEADERS = new HashSet<>(Arrays.asList(
40+
"connection", "keep-alive", "transfer-encoding", "content-length"
41+
));
42+
43+
private final String token;
44+
45+
private final List<String> allowedUrls;
46+
47+
private final HttpClient client;
48+
49+
public AIProxyREST(ITokenService tokenService, Vertx vertx, MongoClient db, String token, String allowedUrls) {
50+
super(tokenService, db, User.class);
51+
this.token = token;
52+
this.allowedUrls = parseAllowedUrls(allowedUrls);
53+
54+
/*
55+
* No per request timeout, so streaming (e.g. SSE) responses are not cut off.
56+
* The idle timeout closes connections without any traffic.
57+
*/
58+
HttpClientOptions options = new HttpClientOptions()
59+
.setConnectTimeout(20000)
60+
.setIdleTimeout(120);
61+
this.client = vertx.createHttpClient(options);
62+
}
63+
64+
private static List<String> parseAllowedUrls(String csv) {
65+
List<String> result = new ArrayList<>();
66+
if (csv != null) {
67+
for (String entry : csv.split(",")) {
68+
String trimmed = entry.trim();
69+
if (!trimmed.isEmpty()) {
70+
result.add(trimmed);
71+
}
72+
}
73+
}
74+
return result;
75+
}
76+
77+
/**
78+
* The target URL is allowed if it starts with one of the configured
79+
* allowed URLs, e.g. "https://api.openai.com" allows any path below it.
80+
*/
81+
private boolean isUrlAllowed(URI target) {
82+
String url = target.toString();
83+
for (String allowed : allowedUrls) {
84+
if (url.startsWith(allowed)) {
85+
return true;
86+
}
87+
}
88+
return false;
89+
}
90+
91+
public void proxy(RoutingContext event) {
92+
logger.info("proxy() > enter > " + event.request().method() + " " + event.request().path());
93+
94+
User user = getUser(event);
95+
if (!user.hasRole(User.USER)) {
96+
error("proxy", "User " + user + " tried to use the AI proxy", event);
97+
returnError(event, 401);
98+
return;
99+
}
100+
101+
URI target = getTargetURI(event);
102+
if (target == null) {
103+
error("proxy", "No valid " + HEADER_URL + " header", event);
104+
returnError(event, 400);
105+
return;
106+
}
107+
108+
if (!isUrlAllowed(target)) {
109+
error("proxy", "User " + user + " tried to proxy to not allowed URL " + target, event);
110+
returnError(event, 403);
111+
return;
112+
}
113+
114+
mongo.findOne(table, User.findById(user.getId()), null, res -> {
115+
if (res.succeeded() && res.result() != null) {
116+
checkUsageAndForward(event, res.result(), target);
117+
} else {
118+
error("proxy", "Could not load user " + user.getId(), event);
119+
returnError(event, 404);
120+
}
121+
});
122+
}
123+
124+
private void checkUsageAndForward(RoutingContext event, JsonObject user, URI target) {
125+
126+
int usage = user.getInteger(User.FIELD_AI_USAGE, 0);
127+
if (usage >= User.AI_USAGE_LIMIT) {
128+
error("checkUsageAndForward", "User " + user.getString("_id") + " has no AI usage left", event);
129+
returnNoTokenLeft(event);
130+
return;
131+
}
132+
133+
JsonObject inc = new JsonObject()
134+
.put(User.FIELD_AI_USAGE, 1)
135+
.put(User.FIELD_AI_USAGE_TOTAL, 1);
136+
JsonObject update = new JsonObject().put("$inc", inc);
137+
138+
mongo.updateCollection(table, User.findById(user.getString("_id")), update, res -> {
139+
if (!res.succeeded()) {
140+
logger.error("checkUsageAndForward() > Could not update AI usage", res.cause());
141+
returnError(event, 500);
142+
return;
143+
}
144+
forward(event, target);
145+
});
146+
147+
148+
}
149+
150+
private void forward(RoutingContext event, URI target) {
151+
152+
HttpClientRequest request = client.requestAbs(event.request().method(), target.toString());
153+
154+
request.handler(response -> returnStream(event, response));
155+
156+
request.exceptionHandler(err -> {
157+
logger.error("forward() > Could not forward to target", err);
158+
if (!event.response().headWritten() && !event.response().ended()) {
159+
returnError(event, 502);
160+
} else if (!event.response().ended()) {
161+
event.response().close();
162+
}
163+
});
164+
165+
for (Map.Entry<String, String> header : event.request().headers().entries()) {
166+
if (!EXCLUDED_HEADERS.contains(header.getKey().toLowerCase())) {
167+
request.putHeader(header.getKey(), header.getValue());
168+
}
169+
}
170+
171+
/*
172+
* Callers never provide their own credentials for the target. We
173+
* always inject the shared server side token instead.
174+
*/
175+
request.putHeader(HEADER_AUTHORIZATION, BEARER_PREFIX + this.token);
176+
177+
/*
178+
* If the caller disconnects, close the upstream connection, so
179+
* we do not keep streaming into the void.
180+
*/
181+
event.response().closeHandler(v -> {
182+
HttpConnection connection = request.connection();
183+
if (connection != null) {
184+
connection.close();
185+
}
186+
});
187+
188+
Buffer body = event.getBody();
189+
if (body != null && body.length() > 0) {
190+
request.end(body);
191+
} else {
192+
request.end();
193+
}
194+
}
195+
196+
/**
197+
* Pump the target response directly into the caller response, so
198+
* chunked and SSE streams arrive incrementally.
199+
*/
200+
private void returnStream(RoutingContext event, HttpClientResponse response) {
201+
202+
HttpServerResponse out = event.response();
203+
out.setStatusCode(response.statusCode());
204+
205+
for (Map.Entry<String, String> header : response.headers().entries()) {
206+
if (!EXCLUDED_RESPONSE_HEADERS.contains(header.getKey().toLowerCase())) {
207+
out.putHeader(header.getKey(), header.getValue());
208+
}
209+
}
210+
out.setChunked(true);
211+
212+
Pump.pump(response, out).start();
213+
214+
response.endHandler(v -> {
215+
if (!out.ended()) {
216+
out.end();
217+
}
218+
});
219+
response.exceptionHandler(err -> {
220+
logger.error("returnStream() > Stream from target failed", err);
221+
if (!out.ended()) {
222+
out.close();
223+
}
224+
});
225+
}
226+
227+
private void returnNoTokenLeft(RoutingContext event) {
228+
JsonObject result = new JsonObject().put("type", "NoTokenLeft");
229+
event.response().setStatusCode(403);
230+
event.response().putHeader("content-type", "application/json");
231+
event.response().end(result.encodePrettily());
232+
}
233+
234+
private URI getTargetURI(RoutingContext event) {
235+
String url = event.request().getHeader(HEADER_URL);
236+
if (url == null || url.trim().isEmpty()) {
237+
return null;
238+
}
239+
try {
240+
URI uri = new URI(url.trim());
241+
String scheme = uri.getScheme();
242+
if (uri.getHost() == null) {
243+
return null;
244+
}
245+
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
246+
return null;
247+
}
248+
return uri;
249+
} catch (Exception e) {
250+
return null;
251+
}
252+
}
253+
254+
}

0 commit comments

Comments
 (0)