Skip to content

Commit 5acb644

Browse files
committed
Improvements
1 parent ebfd666 commit 5acb644

8 files changed

Lines changed: 454 additions & 20 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
* Copyright © 2021-present Arcade Data Ltd ([email protected])
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-FileCopyrightText: 2021-present Arcade Data Ltd ([email protected])
17+
* SPDX-License-Identifier: Apache-2.0
18+
*/
19+
package com.arcadedb.server.ai;
20+
21+
import com.arcadedb.Constants;
22+
import com.arcadedb.log.LogManager;
23+
import com.arcadedb.serializer.json.JSONObject;
24+
import com.arcadedb.server.http.HttpServer;
25+
import com.arcadedb.server.http.handler.AbstractServerHttpHandler;
26+
import com.arcadedb.server.http.handler.ExecutionResponse;
27+
import com.arcadedb.server.security.ServerSecurityUser;
28+
import io.undertow.server.HttpServerExchange;
29+
30+
import java.net.InetAddress;
31+
import java.net.NetworkInterface;
32+
import java.net.URI;
33+
import java.net.http.HttpClient;
34+
import java.net.http.HttpRequest;
35+
import java.net.http.HttpResponse;
36+
import java.security.MessageDigest;
37+
import java.time.Duration;
38+
import java.util.Enumeration;
39+
import java.util.logging.Level;
40+
41+
/**
42+
* POST /api/v1/ai/activate - Activates an AI subscription.
43+
* Collects hardware fingerprint, validates the key against the gateway, and saves to config/ai.json.
44+
*/
45+
public class AiActivateHandler extends AbstractServerHttpHandler {
46+
private final AiConfiguration config;
47+
private final HttpClient httpClient;
48+
49+
public AiActivateHandler(final HttpServer httpServer, final AiConfiguration config) {
50+
super(httpServer);
51+
this.config = config;
52+
this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
53+
}
54+
55+
@Override
56+
protected boolean mustExecuteOnWorkerThread() {
57+
return true;
58+
}
59+
60+
@Override
61+
protected ExecutionResponse execute(final HttpServerExchange exchange, final ServerSecurityUser user, final JSONObject payload) {
62+
if (payload == null)
63+
return new ExecutionResponse(400, errorJson("Request body is required"));
64+
65+
final String subscriptionKey = payload.getString("subscriptionKey", "");
66+
if (subscriptionKey.isEmpty())
67+
return new ExecutionResponse(400, errorJson("Subscription key is required"));
68+
69+
try {
70+
final String serverVersion = Constants.getVersion();
71+
final String hardwareId = getHardwareId();
72+
final String clientIp = getClientIp(exchange);
73+
74+
// Validate the key against the gateway
75+
final JSONObject activationRequest = new JSONObject();
76+
activationRequest.put("subscriptionKey", subscriptionKey);
77+
activationRequest.put("serverVersion", serverVersion);
78+
activationRequest.put("hardwareId", hardwareId);
79+
80+
final HttpRequest request = HttpRequest.newBuilder()//
81+
.uri(URI.create(config.getGatewayUrl() + "/api/activate"))//
82+
.header("Content-Type", "application/json")//
83+
.POST(HttpRequest.BodyPublishers.ofString(activationRequest.toString()))//
84+
.timeout(Duration.ofSeconds(15))//
85+
.build();
86+
87+
final HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
88+
89+
if (response.statusCode() != 200) {
90+
String errorMsg = "Activation failed";
91+
try {
92+
final JSONObject errBody = new JSONObject(response.body());
93+
errorMsg = errBody.getString("error", errorMsg);
94+
} catch (final Exception ignored) {
95+
}
96+
return new ExecutionResponse(response.statusCode(), errorJson(errorMsg));
97+
}
98+
99+
// Activation successful - save to config/ai.json
100+
config.activate(subscriptionKey, clientIp, hardwareId, serverVersion);
101+
102+
LogManager.instance().log(this, Level.INFO, "AI subscription activated (user=%s, ip=%s)", user.getName(), clientIp);
103+
104+
return new ExecutionResponse(200, new JSONObject().put("activated", true).toString());
105+
106+
} catch (final Exception e) {
107+
LogManager.instance().log(this, Level.WARNING, "AI activation error: %s", e.getMessage());
108+
return new ExecutionResponse(500, errorJson("Activation failed: " + e.getMessage()));
109+
}
110+
}
111+
112+
/**
113+
* Generates a hardware fingerprint by hashing MAC addresses + hostname.
114+
* This provides a stable identifier for the server without exposing raw MAC addresses.
115+
*/
116+
static String getHardwareId() {
117+
try {
118+
final StringBuilder raw = new StringBuilder();
119+
120+
// Collect all non-loopback MAC addresses
121+
final Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
122+
while (interfaces.hasMoreElements()) {
123+
final NetworkInterface ni = interfaces.nextElement();
124+
if (ni.isLoopback() || ni.isVirtual())
125+
continue;
126+
final byte[] mac = ni.getHardwareAddress();
127+
if (mac != null) {
128+
for (final byte b : mac)
129+
raw.append(String.format("%02x", b));
130+
raw.append("|");
131+
}
132+
}
133+
134+
// Add hostname for extra uniqueness
135+
raw.append(InetAddress.getLocalHost().getHostName());
136+
137+
// Hash it to produce a stable, non-reversible fingerprint
138+
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
139+
final byte[] hash = digest.digest(raw.toString().getBytes());
140+
final StringBuilder hex = new StringBuilder();
141+
for (int i = 0; i < 16; i++) // Use first 16 bytes (128 bits) for a shorter ID
142+
hex.append(String.format("%02x", hash[i]));
143+
return hex.toString();
144+
} catch (final Exception e) {
145+
LogManager.instance().log(AiActivateHandler.class, Level.FINE, "Could not generate hardware ID: %s", e.getMessage());
146+
return "unknown";
147+
}
148+
}
149+
150+
private static String getClientIp(final HttpServerExchange exchange) {
151+
// Check for X-Forwarded-For (reverse proxy)
152+
final String forwarded = exchange.getRequestHeaders().getFirst("X-Forwarded-For");
153+
if (forwarded != null && !forwarded.isEmpty())
154+
return forwarded.split(",")[0].trim();
155+
return exchange.getSourceAddress().getAddress().getHostAddress();
156+
}
157+
158+
private static String errorJson(final String message) {
159+
return new JSONObject().put("error", message).toString();
160+
}
161+
}

server/src/main/java/com/arcadedb/server/ai/AiChatHandler.java

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
package com.arcadedb.server.ai;
2020

21+
import com.arcadedb.Constants;
2122
import com.arcadedb.log.LogManager;
2223
import com.arcadedb.serializer.json.JSONArray;
2324
import com.arcadedb.serializer.json.JSONObject;
@@ -31,10 +32,14 @@
3132
import com.arcadedb.server.security.ServerSecurityUser;
3233
import io.undertow.server.HttpServerExchange;
3334

35+
import java.io.IOException;
36+
import java.net.ConnectException;
3437
import java.net.URI;
3538
import java.net.http.HttpClient;
39+
import java.net.http.HttpConnectTimeoutException;
3640
import java.net.http.HttpRequest;
3741
import java.net.http.HttpResponse;
42+
import java.net.http.HttpTimeoutException;
3843
import java.time.Duration;
3944
import java.util.logging.Level;
4045

@@ -125,6 +130,8 @@ protected ExecutionResponse execute(final HttpServerExchange exchange, final Ser
125130
gatewayRequest.put("message", message);
126131
gatewayRequest.put("history", history);
127132
gatewayRequest.put("database", database);
133+
gatewayRequest.put("hardwareId", AiActivateHandler.getHardwareId());
134+
gatewayRequest.put("serverVersion", Constants.getVersion());
128135

129136
final JSONObject gatewayResponse = callGateway(gatewayRequest);
130137

@@ -156,9 +163,27 @@ protected ExecutionResponse execute(final HttpServerExchange exchange, final Ser
156163

157164
} catch (final SecurityException e) {
158165
throw e; // Let AbstractServerHttpHandler handle security exceptions
166+
} catch (final AiTokenException e) {
167+
return new ExecutionResponse(e.getHttpStatus(), e.getJsonResponse());
168+
} catch (final ConnectException | HttpConnectTimeoutException e) {
169+
LogManager.instance().log(this, Level.WARNING, "AI gateway unreachable: %s", e.getMessage());
170+
return new ExecutionResponse(503, new JSONObject()//
171+
.put("error", "AI service is temporarily unreachable. Please try again later.")//
172+
.put("code", "gateway_unreachable").toString());
173+
} catch (final HttpTimeoutException e) {
174+
LogManager.instance().log(this, Level.WARNING, "AI gateway timeout: %s", e.getMessage());
175+
return new ExecutionResponse(504, new JSONObject()//
176+
.put("error", "AI service took too long to respond. Please try again later.")//
177+
.put("code", "gateway_timeout").toString());
178+
} catch (final IOException e) {
179+
LogManager.instance().log(this, Level.WARNING, "AI gateway I/O error: %s", e.getMessage());
180+
return new ExecutionResponse(503, new JSONObject()//
181+
.put("error", "AI service is temporarily unavailable. Please try again later.")//
182+
.put("code", "gateway_unreachable").toString());
159183
} catch (final Exception e) {
160184
LogManager.instance().log(this, Level.WARNING, "Error processing AI chat request: %s", e.getMessage());
161-
return new ExecutionResponse(500, new JSONObject().put("error", "Failed to process AI request: " + e.getMessage()).toString());
185+
return new ExecutionResponse(500, new JSONObject()//
186+
.put("error", "An unexpected error occurred. Please try again later.").toString());
162187
}
163188
}
164189

@@ -173,8 +198,16 @@ private JSONObject callGateway(final JSONObject requestBody) throws Exception {
173198

174199
final HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
175200

176-
if (response.statusCode() == 401 || response.statusCode() == 403)
177-
throw new SecurityException("Invalid or expired subscription token");
201+
if (response.statusCode() == 401 || response.statusCode() == 403) {
202+
// Parse the gateway error to get the specific code (token_invalid, token_expired, etc.)
203+
final JSONObject errBody = new JSONObject(response.body());
204+
final String code = errBody.getString("code", "token_invalid");
205+
final String errorMsg = errBody.getString("error", "Invalid or expired subscription token");
206+
final JSONObject errorResponse = new JSONObject();
207+
errorResponse.put("error", errorMsg);
208+
errorResponse.put("code", code);
209+
throw new AiTokenException(response.statusCode(), errorResponse.toString());
210+
}
178211

179212
if (response.statusCode() != 200)
180213
throw new RuntimeException("Gateway returned status " + response.statusCode() + ": " + response.body());

server/src/main/java/com/arcadedb/server/ai/AiChatsHandler.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
* Handles chat CRUD operations:
3434
* - GET /api/v1/ai/chats - list all chats for the current user
3535
* - GET /api/v1/ai/chats/{id} - get a specific chat
36+
* - PUT /api/v1/ai/chats/{id} - update chat messages (e.g., after deleting a message)
3637
* - DELETE /api/v1/ai/chats/{id} - delete a specific chat
3738
*/
3839
public class AiChatsHandler extends AbstractServerHttpHandler {
@@ -56,6 +57,11 @@ protected ExecutionResponse execute(final HttpServerExchange exchange, final Ser
5657
return getChat(username, chatId);
5758
else
5859
return listChats(username);
60+
} else if ("PUT".equals(method)) {
61+
if (chatId != null)
62+
return updateChat(username, chatId, payload);
63+
else
64+
return new ExecutionResponse(400, errorJson("Chat ID is required for PUT"));
5965
} else if ("DELETE".equals(method)) {
6066
if (chatId != null)
6167
return deleteChat(username, chatId);
@@ -80,6 +86,24 @@ private ExecutionResponse getChat(final String username, final String chatId) {
8086
return new ExecutionResponse(200, chat.toString());
8187
}
8288

89+
private ExecutionResponse updateChat(final String username, final String chatId, final JSONObject payload) {
90+
if (payload == null)
91+
return new ExecutionResponse(400, errorJson("Request body is required"));
92+
93+
final JSONObject chat = chatStorage.getChat(username, chatId);
94+
if (chat == null)
95+
return new ExecutionResponse(404, errorJson("Chat not found"));
96+
97+
final JSONArray messages = payload.getJSONArray("messages", null);
98+
if (messages != null) {
99+
chat.put("messages", messages);
100+
chat.put("updated", java.time.Instant.now().toString());
101+
chatStorage.saveChat(username, chat);
102+
}
103+
104+
return new ExecutionResponse(200, chat.toString());
105+
}
106+
83107
private ExecutionResponse deleteChat(final String username, final String chatId) {
84108
final boolean deleted = chatStorage.deleteChat(username, chatId);
85109
if (!deleted)

server/src/main/java/com/arcadedb/server/ai/AiConfiguration.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import com.arcadedb.serializer.json.JSONObject;
2323

2424
import java.io.File;
25+
import java.io.FileOutputStream;
26+
import java.io.OutputStreamWriter;
2527
import java.nio.charset.StandardCharsets;
2628
import java.nio.file.Files;
2729
import java.nio.file.Paths;
@@ -37,6 +39,10 @@ public class AiConfiguration {
3739

3840
private volatile String subscriptionToken = "";
3941
private volatile String gatewayUrl = DEFAULT_GATEWAY_URL;
42+
private volatile String activatedAt = "";
43+
private volatile String activationIp = "";
44+
private volatile String hardwareId = "";
45+
private volatile String serverVersion = "";
4046

4147
public AiConfiguration(final String rootPath) {
4248
this.rootPath = rootPath;
@@ -53,11 +59,36 @@ public synchronized void load() {
5359

5460
subscriptionToken = json.getString("subscriptionToken", "");
5561
gatewayUrl = json.getString("gatewayUrl", DEFAULT_GATEWAY_URL);
62+
activatedAt = json.getString("activatedAt", "");
63+
activationIp = json.getString("activationIp", "");
64+
hardwareId = json.getString("hardwareId", "");
65+
serverVersion = json.getString("serverVersion", "");
5666
} catch (final Exception e) {
5767
LogManager.instance().log(this, Level.WARNING, "Error loading AI configuration: %s", e.getMessage());
5868
}
5969
}
6070

71+
public synchronized void save() {
72+
final File configDir = Paths.get(rootPath, "config").toFile();
73+
if (!configDir.exists())
74+
configDir.mkdirs();
75+
76+
try (final OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(getConfigFile()), StandardCharsets.UTF_8)) {
77+
writer.write(toFullJSON().toString(2));
78+
} catch (final Exception e) {
79+
LogManager.instance().log(this, Level.WARNING, "Error saving AI configuration: %s", e.getMessage());
80+
}
81+
}
82+
83+
public synchronized void activate(final String token, final String ip, final String hwId, final String version) {
84+
this.subscriptionToken = token;
85+
this.activatedAt = java.time.Instant.now().toString();
86+
this.activationIp = ip;
87+
this.hardwareId = hwId;
88+
this.serverVersion = version;
89+
save();
90+
}
91+
6192
public boolean isConfigured() {
6293
return subscriptionToken != null && !subscriptionToken.isEmpty();
6394
}
@@ -70,13 +101,30 @@ public String getGatewayUrl() {
70101
return gatewayUrl;
71102
}
72103

104+
/**
105+
* Returns public config (no token exposed to the browser).
106+
*/
73107
public synchronized JSONObject toJSON() {
74108
final JSONObject json = new JSONObject();
75109
json.put("configured", isConfigured());
76110
json.put("gatewayUrl", gatewayUrl);
77111
return json;
78112
}
79113

114+
/**
115+
* Returns full config for persistence.
116+
*/
117+
private JSONObject toFullJSON() {
118+
final JSONObject json = new JSONObject();
119+
json.put("subscriptionToken", subscriptionToken);
120+
json.put("gatewayUrl", gatewayUrl);
121+
json.put("activatedAt", activatedAt);
122+
json.put("activationIp", activationIp);
123+
json.put("hardwareId", hardwareId);
124+
json.put("serverVersion", serverVersion);
125+
return json;
126+
}
127+
80128
private File getConfigFile() {
81129
return Paths.get(rootPath, "config", "ai.json").toFile();
82130
}

0 commit comments

Comments
 (0)