@xtr-dev/rondevu-server 0.0.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/API.md +39 -9
- package/CLAUDE.md +47 -0
- package/README.md +144 -187
- package/build.js +12 -0
- package/dist/index.js +799 -266
- package/dist/index.js.map +4 -4
- package/migrations/0001_add_peer_id.sql +21 -0
- package/migrations/0002_remove_topics.sql +22 -0
- package/migrations/0003_remove_origin.sql +29 -0
- package/migrations/0004_add_secret.sql +4 -0
- package/migrations/schema.sql +18 -0
- package/package.json +4 -3
- package/src/app.ts +421 -127
- package/src/bloom.ts +66 -0
- package/src/config.ts +27 -2
- package/src/crypto.ts +149 -0
- package/src/index.ts +28 -12
- package/src/middleware/auth.ts +51 -0
- package/src/storage/d1.ts +394 -0
- package/src/storage/hash-id.ts +37 -0
- package/src/storage/sqlite.ts +323 -178
- package/src/storage/types.ts +128 -54
- package/src/worker.ts +51 -16
- package/wrangler.toml +45 -0
- package/DEPLOYMENT.md +0 -346
- package/src/storage/kv.ts +0 -241
package/dist/index.js
CHANGED
|
@@ -28,173 +28,585 @@ var import_node_server = require("@hono/node-server");
|
|
|
28
28
|
// src/app.ts
|
|
29
29
|
var import_hono = require("hono");
|
|
30
30
|
var import_cors = require("hono/cors");
|
|
31
|
+
|
|
32
|
+
// src/crypto.ts
|
|
33
|
+
var ALGORITHM = "AES-GCM";
|
|
34
|
+
var IV_LENGTH = 12;
|
|
35
|
+
var KEY_LENGTH = 32;
|
|
36
|
+
function generatePeerId() {
|
|
37
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
38
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
39
|
+
}
|
|
40
|
+
function generateSecretKey() {
|
|
41
|
+
const bytes = crypto.getRandomValues(new Uint8Array(KEY_LENGTH));
|
|
42
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
43
|
+
}
|
|
44
|
+
function hexToBytes(hex) {
|
|
45
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
46
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
47
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
48
|
+
}
|
|
49
|
+
return bytes;
|
|
50
|
+
}
|
|
51
|
+
function bytesToBase64(bytes) {
|
|
52
|
+
const binString = Array.from(
|
|
53
|
+
bytes,
|
|
54
|
+
(byte) => String.fromCodePoint(byte)
|
|
55
|
+
).join("");
|
|
56
|
+
return btoa(binString);
|
|
57
|
+
}
|
|
58
|
+
function base64ToBytes(base64) {
|
|
59
|
+
const binString = atob(base64);
|
|
60
|
+
return Uint8Array.from(binString, (char) => char.codePointAt(0));
|
|
61
|
+
}
|
|
62
|
+
async function encryptPeerId(peerId, secretKeyHex) {
|
|
63
|
+
const keyBytes = hexToBytes(secretKeyHex);
|
|
64
|
+
if (keyBytes.length !== KEY_LENGTH) {
|
|
65
|
+
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
|
|
66
|
+
}
|
|
67
|
+
const key = await crypto.subtle.importKey(
|
|
68
|
+
"raw",
|
|
69
|
+
keyBytes,
|
|
70
|
+
{ name: ALGORITHM, length: 256 },
|
|
71
|
+
false,
|
|
72
|
+
["encrypt"]
|
|
73
|
+
);
|
|
74
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
75
|
+
const encoder = new TextEncoder();
|
|
76
|
+
const data = encoder.encode(peerId);
|
|
77
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
78
|
+
{ name: ALGORITHM, iv },
|
|
79
|
+
key,
|
|
80
|
+
data
|
|
81
|
+
);
|
|
82
|
+
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
|
83
|
+
combined.set(iv, 0);
|
|
84
|
+
combined.set(new Uint8Array(encrypted), iv.length);
|
|
85
|
+
return bytesToBase64(combined);
|
|
86
|
+
}
|
|
87
|
+
async function decryptPeerId(encryptedSecret, secretKeyHex) {
|
|
88
|
+
try {
|
|
89
|
+
const keyBytes = hexToBytes(secretKeyHex);
|
|
90
|
+
if (keyBytes.length !== KEY_LENGTH) {
|
|
91
|
+
throw new Error(`Secret key must be ${KEY_LENGTH * 2} hex characters (${KEY_LENGTH} bytes)`);
|
|
92
|
+
}
|
|
93
|
+
const combined = base64ToBytes(encryptedSecret);
|
|
94
|
+
const iv = combined.slice(0, IV_LENGTH);
|
|
95
|
+
const ciphertext = combined.slice(IV_LENGTH);
|
|
96
|
+
const key = await crypto.subtle.importKey(
|
|
97
|
+
"raw",
|
|
98
|
+
keyBytes,
|
|
99
|
+
{ name: ALGORITHM, length: 256 },
|
|
100
|
+
false,
|
|
101
|
+
["decrypt"]
|
|
102
|
+
);
|
|
103
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
104
|
+
{ name: ALGORITHM, iv },
|
|
105
|
+
key,
|
|
106
|
+
ciphertext
|
|
107
|
+
);
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
return decoder.decode(decrypted);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
throw new Error("Failed to decrypt peer ID: invalid secret or secret key");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
async function validateCredentials(peerId, encryptedSecret, secretKey) {
|
|
115
|
+
try {
|
|
116
|
+
const decryptedPeerId = await decryptPeerId(encryptedSecret, secretKey);
|
|
117
|
+
return decryptedPeerId === peerId;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/middleware/auth.ts
|
|
124
|
+
function createAuthMiddleware(authSecret) {
|
|
125
|
+
return async (c, next) => {
|
|
126
|
+
const authHeader = c.req.header("Authorization");
|
|
127
|
+
if (!authHeader) {
|
|
128
|
+
return c.json({ error: "Missing Authorization header" }, 401);
|
|
129
|
+
}
|
|
130
|
+
const parts = authHeader.split(" ");
|
|
131
|
+
if (parts.length !== 2 || parts[0] !== "Bearer") {
|
|
132
|
+
return c.json({ error: "Invalid Authorization header format. Expected: Bearer {peerId}:{secret}" }, 401);
|
|
133
|
+
}
|
|
134
|
+
const credentials = parts[1].split(":");
|
|
135
|
+
if (credentials.length !== 2) {
|
|
136
|
+
return c.json({ error: "Invalid credentials format. Expected: {peerId}:{secret}" }, 401);
|
|
137
|
+
}
|
|
138
|
+
const [peerId, encryptedSecret] = credentials;
|
|
139
|
+
const isValid = await validateCredentials(peerId, encryptedSecret, authSecret);
|
|
140
|
+
if (!isValid) {
|
|
141
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
142
|
+
}
|
|
143
|
+
c.set("peerId", peerId);
|
|
144
|
+
await next();
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function getAuthenticatedPeerId(c) {
|
|
148
|
+
const peerId = c.get("peerId");
|
|
149
|
+
if (!peerId) {
|
|
150
|
+
throw new Error("No authenticated peer ID in context");
|
|
151
|
+
}
|
|
152
|
+
return peerId;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/bloom.ts
|
|
156
|
+
var BloomFilter = class {
|
|
157
|
+
/**
|
|
158
|
+
* Creates a bloom filter from a base64 encoded bit array
|
|
159
|
+
*/
|
|
160
|
+
constructor(base64Data, numHashes = 3) {
|
|
161
|
+
const binaryString = atob(base64Data);
|
|
162
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
163
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
164
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
165
|
+
}
|
|
166
|
+
this.bits = bytes;
|
|
167
|
+
this.size = this.bits.length * 8;
|
|
168
|
+
this.numHashes = numHashes;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Test if a peer ID might be in the filter
|
|
172
|
+
* Returns true if possibly in set, false if definitely not in set
|
|
173
|
+
*/
|
|
174
|
+
test(peerId) {
|
|
175
|
+
for (let i = 0; i < this.numHashes; i++) {
|
|
176
|
+
const hash = this.hash(peerId, i);
|
|
177
|
+
const index = hash % this.size;
|
|
178
|
+
const byteIndex = Math.floor(index / 8);
|
|
179
|
+
const bitIndex = index % 8;
|
|
180
|
+
if (!(this.bits[byteIndex] & 1 << bitIndex)) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Simple hash function (FNV-1a variant)
|
|
188
|
+
*/
|
|
189
|
+
hash(str, seed) {
|
|
190
|
+
let hash = 2166136261 ^ seed;
|
|
191
|
+
for (let i = 0; i < str.length; i++) {
|
|
192
|
+
hash ^= str.charCodeAt(i);
|
|
193
|
+
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
|
|
194
|
+
}
|
|
195
|
+
return hash >>> 0;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
function parseBloomFilter(base64) {
|
|
199
|
+
try {
|
|
200
|
+
return new BloomFilter(base64);
|
|
201
|
+
} catch {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// src/app.ts
|
|
31
207
|
function createApp(storage, config) {
|
|
32
208
|
const app = new import_hono.Hono();
|
|
209
|
+
const authMiddleware = createAuthMiddleware(config.authSecret);
|
|
33
210
|
app.use("/*", (0, import_cors.cors)({
|
|
34
|
-
origin:
|
|
35
|
-
|
|
36
|
-
|
|
211
|
+
origin: (origin) => {
|
|
212
|
+
if (config.corsOrigins.length === 1 && config.corsOrigins[0] === "*") {
|
|
213
|
+
return origin;
|
|
214
|
+
}
|
|
215
|
+
if (config.corsOrigins.includes(origin)) {
|
|
216
|
+
return origin;
|
|
217
|
+
}
|
|
218
|
+
return config.corsOrigins[0];
|
|
219
|
+
},
|
|
220
|
+
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
221
|
+
allowHeaders: ["Content-Type", "Origin", "Authorization"],
|
|
37
222
|
exposeHeaders: ["Content-Type"],
|
|
38
223
|
maxAge: 600,
|
|
39
224
|
credentials: true
|
|
40
225
|
}));
|
|
41
|
-
app.get("/",
|
|
226
|
+
app.get("/", (c) => {
|
|
227
|
+
return c.json({
|
|
228
|
+
version: config.version,
|
|
229
|
+
name: "Rondevu",
|
|
230
|
+
description: "Topic-based peer discovery and signaling server"
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
app.get("/health", (c) => {
|
|
234
|
+
return c.json({
|
|
235
|
+
status: "ok",
|
|
236
|
+
timestamp: Date.now(),
|
|
237
|
+
version: config.version
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
app.post("/register", async (c) => {
|
|
42
241
|
try {
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
242
|
+
const peerId = generatePeerId();
|
|
243
|
+
const secret = await encryptPeerId(peerId, config.authSecret);
|
|
244
|
+
return c.json({
|
|
245
|
+
peerId,
|
|
246
|
+
secret
|
|
247
|
+
}, 200);
|
|
48
248
|
} catch (err) {
|
|
49
|
-
console.error("Error
|
|
249
|
+
console.error("Error registering peer:", err);
|
|
50
250
|
return c.json({ error: "Internal server error" }, 500);
|
|
51
251
|
}
|
|
52
252
|
});
|
|
53
|
-
app.
|
|
253
|
+
app.post("/offers", authMiddleware, async (c) => {
|
|
54
254
|
try {
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
58
|
-
return c.json({ error: "Missing required parameter:
|
|
255
|
+
const body = await c.req.json();
|
|
256
|
+
const { offers } = body;
|
|
257
|
+
if (!Array.isArray(offers) || offers.length === 0) {
|
|
258
|
+
return c.json({ error: "Missing or invalid required parameter: offers (must be non-empty array)" }, 400);
|
|
59
259
|
}
|
|
60
|
-
if (
|
|
61
|
-
return c.json({ error:
|
|
260
|
+
if (offers.length > config.maxOffersPerRequest) {
|
|
261
|
+
return c.json({ error: `Too many offers. Maximum ${config.maxOffersPerRequest} per request` }, 400);
|
|
62
262
|
}
|
|
63
|
-
const
|
|
263
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
264
|
+
const offerRequests = [];
|
|
265
|
+
for (const offer of offers) {
|
|
266
|
+
if (!offer.sdp || typeof offer.sdp !== "string") {
|
|
267
|
+
return c.json({ error: "Each offer must have an sdp field" }, 400);
|
|
268
|
+
}
|
|
269
|
+
if (offer.sdp.length > 65536) {
|
|
270
|
+
return c.json({ error: "SDP must be 64KB or less" }, 400);
|
|
271
|
+
}
|
|
272
|
+
if (offer.secret !== void 0) {
|
|
273
|
+
if (typeof offer.secret !== "string") {
|
|
274
|
+
return c.json({ error: "Secret must be a string" }, 400);
|
|
275
|
+
}
|
|
276
|
+
if (offer.secret.length > 128) {
|
|
277
|
+
return c.json({ error: "Secret must be 128 characters or less" }, 400);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (!Array.isArray(offer.topics) || offer.topics.length === 0) {
|
|
281
|
+
return c.json({ error: "Each offer must have a non-empty topics array" }, 400);
|
|
282
|
+
}
|
|
283
|
+
if (offer.topics.length > config.maxTopicsPerOffer) {
|
|
284
|
+
return c.json({ error: `Too many topics. Maximum ${config.maxTopicsPerOffer} per offer` }, 400);
|
|
285
|
+
}
|
|
286
|
+
for (const topic of offer.topics) {
|
|
287
|
+
if (typeof topic !== "string" || topic.length === 0 || topic.length > 256) {
|
|
288
|
+
return c.json({ error: "Each topic must be a string between 1 and 256 characters" }, 400);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
let ttl = offer.ttl || config.offerDefaultTtl;
|
|
292
|
+
if (ttl < config.offerMinTtl) {
|
|
293
|
+
ttl = config.offerMinTtl;
|
|
294
|
+
}
|
|
295
|
+
if (ttl > config.offerMaxTtl) {
|
|
296
|
+
ttl = config.offerMaxTtl;
|
|
297
|
+
}
|
|
298
|
+
offerRequests.push({
|
|
299
|
+
id: offer.id,
|
|
300
|
+
peerId,
|
|
301
|
+
sdp: offer.sdp,
|
|
302
|
+
topics: offer.topics,
|
|
303
|
+
expiresAt: Date.now() + ttl,
|
|
304
|
+
secret: offer.secret
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const createdOffers = await storage.createOffers(offerRequests);
|
|
64
308
|
return c.json({
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
createdAt: s.createdAt,
|
|
71
|
-
expiresAt: s.expiresAt
|
|
309
|
+
offers: createdOffers.map((o) => ({
|
|
310
|
+
id: o.id,
|
|
311
|
+
peerId: o.peerId,
|
|
312
|
+
topics: o.topics,
|
|
313
|
+
expiresAt: o.expiresAt
|
|
72
314
|
}))
|
|
73
|
-
});
|
|
315
|
+
}, 200);
|
|
74
316
|
} catch (err) {
|
|
75
|
-
console.error("Error
|
|
317
|
+
console.error("Error creating offers:", err);
|
|
76
318
|
return c.json({ error: "Internal server error" }, 500);
|
|
77
319
|
}
|
|
78
320
|
});
|
|
79
|
-
app.
|
|
321
|
+
app.get("/offers/by-topic/:topic", async (c) => {
|
|
80
322
|
try {
|
|
81
|
-
const origin = c.req.header("Origin") || c.req.header("origin") || "unknown";
|
|
82
323
|
const topic = c.req.param("topic");
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
324
|
+
const bloomParam = c.req.query("bloom");
|
|
325
|
+
const limitParam = c.req.query("limit");
|
|
326
|
+
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
|
|
327
|
+
let excludePeerIds = [];
|
|
328
|
+
if (bloomParam) {
|
|
329
|
+
const bloom = parseBloomFilter(bloomParam);
|
|
330
|
+
if (!bloom) {
|
|
331
|
+
return c.json({ error: "Invalid bloom filter format" }, 400);
|
|
332
|
+
}
|
|
333
|
+
const allOffers = await storage.getOffersByTopic(topic);
|
|
334
|
+
const excludeSet = /* @__PURE__ */ new Set();
|
|
335
|
+
for (const offer of allOffers) {
|
|
336
|
+
if (bloom.test(offer.peerId)) {
|
|
337
|
+
excludeSet.add(offer.peerId);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
excludePeerIds = Array.from(excludeSet);
|
|
96
341
|
}
|
|
97
|
-
|
|
98
|
-
|
|
342
|
+
let offers = await storage.getOffersByTopic(topic, excludePeerIds.length > 0 ? excludePeerIds : void 0);
|
|
343
|
+
const total = offers.length;
|
|
344
|
+
offers = offers.slice(0, limit);
|
|
345
|
+
return c.json({
|
|
346
|
+
topic,
|
|
347
|
+
offers: offers.map((o) => ({
|
|
348
|
+
id: o.id,
|
|
349
|
+
peerId: o.peerId,
|
|
350
|
+
sdp: o.sdp,
|
|
351
|
+
topics: o.topics,
|
|
352
|
+
expiresAt: o.expiresAt,
|
|
353
|
+
lastSeen: o.lastSeen,
|
|
354
|
+
hasSecret: !!o.secret
|
|
355
|
+
// Indicate if secret is required without exposing it
|
|
356
|
+
})),
|
|
357
|
+
total: bloomParam ? total + excludePeerIds.length : total,
|
|
358
|
+
returned: offers.length
|
|
359
|
+
}, 200);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
console.error("Error fetching offers by topic:", err);
|
|
362
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
app.get("/topics", async (c) => {
|
|
366
|
+
try {
|
|
367
|
+
const limitParam = c.req.query("limit");
|
|
368
|
+
const offsetParam = c.req.query("offset");
|
|
369
|
+
const startsWithParam = c.req.query("startsWith");
|
|
370
|
+
const limit = limitParam ? Math.min(parseInt(limitParam, 10), 200) : 50;
|
|
371
|
+
const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
|
372
|
+
const startsWith = startsWithParam || void 0;
|
|
373
|
+
const result = await storage.getTopics(limit, offset, startsWith);
|
|
374
|
+
return c.json({
|
|
375
|
+
topics: result.topics,
|
|
376
|
+
total: result.total,
|
|
377
|
+
limit,
|
|
378
|
+
offset,
|
|
379
|
+
...startsWith && { startsWith }
|
|
380
|
+
}, 200);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
console.error("Error fetching topics:", err);
|
|
383
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
app.get("/peers/:peerId/offers", async (c) => {
|
|
387
|
+
try {
|
|
388
|
+
const peerId = c.req.param("peerId");
|
|
389
|
+
const offers = await storage.getOffersByPeerId(peerId);
|
|
390
|
+
const topicsSet = /* @__PURE__ */ new Set();
|
|
391
|
+
offers.forEach((o) => o.topics.forEach((t) => topicsSet.add(t)));
|
|
392
|
+
return c.json({
|
|
393
|
+
peerId,
|
|
394
|
+
offers: offers.map((o) => ({
|
|
395
|
+
id: o.id,
|
|
396
|
+
sdp: o.sdp,
|
|
397
|
+
topics: o.topics,
|
|
398
|
+
expiresAt: o.expiresAt,
|
|
399
|
+
lastSeen: o.lastSeen,
|
|
400
|
+
hasSecret: !!o.secret
|
|
401
|
+
// Indicate if secret is required without exposing it
|
|
402
|
+
})),
|
|
403
|
+
topics: Array.from(topicsSet)
|
|
404
|
+
}, 200);
|
|
405
|
+
} catch (err) {
|
|
406
|
+
console.error("Error fetching peer offers:", err);
|
|
407
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
app.get("/offers/mine", authMiddleware, async (c) => {
|
|
411
|
+
try {
|
|
412
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
413
|
+
const offers = await storage.getOffersByPeerId(peerId);
|
|
414
|
+
return c.json({
|
|
415
|
+
peerId,
|
|
416
|
+
offers: offers.map((o) => ({
|
|
417
|
+
id: o.id,
|
|
418
|
+
sdp: o.sdp,
|
|
419
|
+
topics: o.topics,
|
|
420
|
+
createdAt: o.createdAt,
|
|
421
|
+
expiresAt: o.expiresAt,
|
|
422
|
+
lastSeen: o.lastSeen,
|
|
423
|
+
secret: o.secret,
|
|
424
|
+
// Owner can see the secret
|
|
425
|
+
answererPeerId: o.answererPeerId,
|
|
426
|
+
answeredAt: o.answeredAt
|
|
427
|
+
}))
|
|
428
|
+
}, 200);
|
|
429
|
+
} catch (err) {
|
|
430
|
+
console.error("Error fetching own offers:", err);
|
|
431
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
app.delete("/offers/:offerId", authMiddleware, async (c) => {
|
|
435
|
+
try {
|
|
436
|
+
const offerId = c.req.param("offerId");
|
|
437
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
438
|
+
const deleted = await storage.deleteOffer(offerId, peerId);
|
|
439
|
+
if (!deleted) {
|
|
440
|
+
return c.json({ error: "Offer not found or not authorized" }, 404);
|
|
99
441
|
}
|
|
100
|
-
|
|
101
|
-
const code = await storage.createSession(origin, topic, info, offer, expiresAt);
|
|
102
|
-
return c.json({ code }, 200);
|
|
442
|
+
return c.json({ deleted: true }, 200);
|
|
103
443
|
} catch (err) {
|
|
104
|
-
console.error("Error
|
|
444
|
+
console.error("Error deleting offer:", err);
|
|
105
445
|
return c.json({ error: "Internal server error" }, 500);
|
|
106
446
|
}
|
|
107
447
|
});
|
|
108
|
-
app.post("/answer", async (c) => {
|
|
448
|
+
app.post("/offers/:offerId/answer", authMiddleware, async (c) => {
|
|
109
449
|
try {
|
|
110
|
-
const
|
|
450
|
+
const offerId = c.req.param("offerId");
|
|
451
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
111
452
|
const body = await c.req.json();
|
|
112
|
-
const {
|
|
113
|
-
if (!
|
|
114
|
-
return c.json({ error: "Missing or invalid required parameter:
|
|
115
|
-
}
|
|
116
|
-
if (!side || side !== "offerer" && side !== "answerer") {
|
|
117
|
-
return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
|
|
453
|
+
const { sdp, secret } = body;
|
|
454
|
+
if (!sdp || typeof sdp !== "string") {
|
|
455
|
+
return c.json({ error: "Missing or invalid required parameter: sdp" }, 400);
|
|
118
456
|
}
|
|
119
|
-
if (
|
|
120
|
-
return c.json({ error: "
|
|
457
|
+
if (sdp.length > 65536) {
|
|
458
|
+
return c.json({ error: "SDP must be 64KB or less" }, 400);
|
|
121
459
|
}
|
|
122
|
-
if (
|
|
123
|
-
return c.json({ error: "
|
|
460
|
+
if (secret !== void 0 && typeof secret !== "string") {
|
|
461
|
+
return c.json({ error: "Secret must be a string" }, 400);
|
|
124
462
|
}
|
|
125
|
-
const
|
|
126
|
-
if (!
|
|
127
|
-
return c.json({ error:
|
|
463
|
+
const result = await storage.answerOffer(offerId, peerId, sdp, secret);
|
|
464
|
+
if (!result.success) {
|
|
465
|
+
return c.json({ error: result.error }, 400);
|
|
128
466
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
467
|
+
return c.json({
|
|
468
|
+
offerId,
|
|
469
|
+
answererId: peerId,
|
|
470
|
+
answeredAt: Date.now()
|
|
471
|
+
}, 200);
|
|
472
|
+
} catch (err) {
|
|
473
|
+
console.error("Error answering offer:", err);
|
|
474
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
app.get("/offers/answers", authMiddleware, async (c) => {
|
|
478
|
+
try {
|
|
479
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
480
|
+
const offers = await storage.getAnsweredOffers(peerId);
|
|
481
|
+
return c.json({
|
|
482
|
+
answers: offers.map((o) => ({
|
|
483
|
+
offerId: o.id,
|
|
484
|
+
answererId: o.answererPeerId,
|
|
485
|
+
sdp: o.answerSdp,
|
|
486
|
+
answeredAt: o.answeredAt,
|
|
487
|
+
topics: o.topics
|
|
488
|
+
}))
|
|
489
|
+
}, 200);
|
|
142
490
|
} catch (err) {
|
|
143
|
-
console.error("Error
|
|
491
|
+
console.error("Error fetching answers:", err);
|
|
144
492
|
return c.json({ error: "Internal server error" }, 500);
|
|
145
493
|
}
|
|
146
494
|
});
|
|
147
|
-
app.post("/
|
|
495
|
+
app.post("/offers/:offerId/ice-candidates", authMiddleware, async (c) => {
|
|
148
496
|
try {
|
|
149
|
-
const
|
|
497
|
+
const offerId = c.req.param("offerId");
|
|
498
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
150
499
|
const body = await c.req.json();
|
|
151
|
-
const {
|
|
152
|
-
if (!
|
|
153
|
-
return c.json({ error: "Missing or invalid required parameter:
|
|
154
|
-
}
|
|
155
|
-
if (!side || side !== "offerer" && side !== "answerer") {
|
|
156
|
-
return c.json({ error: 'Invalid or missing parameter: side (must be "offerer" or "answerer")' }, 400);
|
|
500
|
+
const { candidates } = body;
|
|
501
|
+
if (!Array.isArray(candidates) || candidates.length === 0) {
|
|
502
|
+
return c.json({ error: "Missing or invalid required parameter: candidates (must be non-empty array)" }, 400);
|
|
157
503
|
}
|
|
158
|
-
const
|
|
159
|
-
if (!
|
|
160
|
-
return c.json({ error: "
|
|
504
|
+
const offer = await storage.getOfferById(offerId);
|
|
505
|
+
if (!offer) {
|
|
506
|
+
return c.json({ error: "Offer not found or expired" }, 404);
|
|
161
507
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
508
|
+
let role;
|
|
509
|
+
if (offer.peerId === peerId) {
|
|
510
|
+
role = "offerer";
|
|
511
|
+
} else if (offer.answererPeerId === peerId) {
|
|
512
|
+
role = "answerer";
|
|
167
513
|
} else {
|
|
168
|
-
return c.json({
|
|
169
|
-
offer: session.offer,
|
|
170
|
-
offerCandidates: session.offerCandidates
|
|
171
|
-
});
|
|
514
|
+
return c.json({ error: "Not authorized to post ICE candidates for this offer" }, 403);
|
|
172
515
|
}
|
|
516
|
+
const added = await storage.addIceCandidates(offerId, peerId, role, candidates);
|
|
517
|
+
return c.json({
|
|
518
|
+
offerId,
|
|
519
|
+
candidatesAdded: added
|
|
520
|
+
}, 200);
|
|
173
521
|
} catch (err) {
|
|
174
|
-
console.error("Error
|
|
522
|
+
console.error("Error adding ICE candidates:", err);
|
|
175
523
|
return c.json({ error: "Internal server error" }, 500);
|
|
176
524
|
}
|
|
177
525
|
});
|
|
178
|
-
app.get("/
|
|
179
|
-
|
|
526
|
+
app.get("/offers/:offerId/ice-candidates", authMiddleware, async (c) => {
|
|
527
|
+
try {
|
|
528
|
+
const offerId = c.req.param("offerId");
|
|
529
|
+
const peerId = getAuthenticatedPeerId(c);
|
|
530
|
+
const sinceParam = c.req.query("since");
|
|
531
|
+
const since = sinceParam ? parseInt(sinceParam, 10) : void 0;
|
|
532
|
+
const offer = await storage.getOfferById(offerId);
|
|
533
|
+
if (!offer) {
|
|
534
|
+
return c.json({ error: "Offer not found or expired" }, 404);
|
|
535
|
+
}
|
|
536
|
+
let targetRole;
|
|
537
|
+
if (offer.peerId === peerId) {
|
|
538
|
+
targetRole = "answerer";
|
|
539
|
+
console.log(`[ICE GET] Offerer ${peerId} requesting answerer ICE candidates for offer ${offerId}, since=${since}, answererPeerId=${offer.answererPeerId}`);
|
|
540
|
+
} else if (offer.answererPeerId === peerId) {
|
|
541
|
+
targetRole = "offerer";
|
|
542
|
+
console.log(`[ICE GET] Answerer ${peerId} requesting offerer ICE candidates for offer ${offerId}, since=${since}, offererPeerId=${offer.peerId}`);
|
|
543
|
+
} else {
|
|
544
|
+
return c.json({ error: "Not authorized to view ICE candidates for this offer" }, 403);
|
|
545
|
+
}
|
|
546
|
+
const candidates = await storage.getIceCandidates(offerId, targetRole, since);
|
|
547
|
+
console.log(`[ICE GET] Found ${candidates.length} candidates for offer ${offerId}, targetRole=${targetRole}, since=${since}`);
|
|
548
|
+
return c.json({
|
|
549
|
+
offerId,
|
|
550
|
+
candidates: candidates.map((c2) => ({
|
|
551
|
+
candidate: c2.candidate,
|
|
552
|
+
peerId: c2.peerId,
|
|
553
|
+
role: c2.role,
|
|
554
|
+
createdAt: c2.createdAt
|
|
555
|
+
}))
|
|
556
|
+
}, 200);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
console.error("Error fetching ICE candidates:", err);
|
|
559
|
+
return c.json({ error: "Internal server error" }, 500);
|
|
560
|
+
}
|
|
180
561
|
});
|
|
181
562
|
return app;
|
|
182
563
|
}
|
|
183
564
|
|
|
184
565
|
// src/config.ts
|
|
185
566
|
function loadConfig() {
|
|
567
|
+
let authSecret = process.env.AUTH_SECRET;
|
|
568
|
+
if (!authSecret) {
|
|
569
|
+
authSecret = generateSecretKey();
|
|
570
|
+
console.warn("WARNING: No AUTH_SECRET provided. Generated temporary secret:", authSecret);
|
|
571
|
+
console.warn("All peer credentials will be invalidated on server restart.");
|
|
572
|
+
console.warn("Set AUTH_SECRET environment variable to persist credentials across restarts.");
|
|
573
|
+
}
|
|
186
574
|
return {
|
|
187
575
|
port: parseInt(process.env.PORT || "3000", 10),
|
|
188
576
|
storageType: process.env.STORAGE_TYPE || "sqlite",
|
|
189
577
|
storagePath: process.env.STORAGE_PATH || ":memory:",
|
|
190
|
-
|
|
191
|
-
|
|
578
|
+
corsOrigins: process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(",").map((o) => o.trim()) : ["*"],
|
|
579
|
+
version: process.env.VERSION || "unknown",
|
|
580
|
+
authSecret,
|
|
581
|
+
offerDefaultTtl: parseInt(process.env.OFFER_DEFAULT_TTL || "60000", 10),
|
|
582
|
+
offerMaxTtl: parseInt(process.env.OFFER_MAX_TTL || "86400000", 10),
|
|
583
|
+
offerMinTtl: parseInt(process.env.OFFER_MIN_TTL || "60000", 10),
|
|
584
|
+
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL || "60000", 10),
|
|
585
|
+
maxOffersPerRequest: parseInt(process.env.MAX_OFFERS_PER_REQUEST || "100", 10),
|
|
586
|
+
maxTopicsPerOffer: parseInt(process.env.MAX_TOPICS_PER_OFFER || "50", 10)
|
|
192
587
|
};
|
|
193
588
|
}
|
|
194
589
|
|
|
195
590
|
// src/storage/sqlite.ts
|
|
196
591
|
var import_better_sqlite3 = __toESM(require("better-sqlite3"));
|
|
197
|
-
|
|
592
|
+
|
|
593
|
+
// src/storage/hash-id.ts
|
|
594
|
+
async function generateOfferHash(sdp, topics) {
|
|
595
|
+
const sanitizedOffer = {
|
|
596
|
+
sdp,
|
|
597
|
+
topics: [...topics].sort()
|
|
598
|
+
// Sort topics for consistency
|
|
599
|
+
};
|
|
600
|
+
const jsonString = JSON.stringify(sanitizedOffer);
|
|
601
|
+
const encoder = new TextEncoder();
|
|
602
|
+
const data = encoder.encode(jsonString);
|
|
603
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
604
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
605
|
+
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
606
|
+
return hashHex;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// src/storage/sqlite.ts
|
|
198
610
|
var SQLiteStorage = class {
|
|
199
611
|
/**
|
|
200
612
|
* Creates a new SQLite storage instance
|
|
@@ -203,193 +615,301 @@ var SQLiteStorage = class {
|
|
|
203
615
|
constructor(path = ":memory:") {
|
|
204
616
|
this.db = new import_better_sqlite3.default(path);
|
|
205
617
|
this.initializeDatabase();
|
|
206
|
-
this.startCleanupInterval();
|
|
207
618
|
}
|
|
208
619
|
/**
|
|
209
|
-
* Initializes database schema
|
|
620
|
+
* Initializes database schema with new topic-based structure
|
|
210
621
|
*/
|
|
211
622
|
initializeDatabase() {
|
|
212
623
|
this.db.exec(`
|
|
213
|
-
CREATE TABLE IF NOT EXISTS
|
|
214
|
-
|
|
215
|
-
|
|
624
|
+
CREATE TABLE IF NOT EXISTS offers (
|
|
625
|
+
id TEXT PRIMARY KEY,
|
|
626
|
+
peer_id TEXT NOT NULL,
|
|
627
|
+
sdp TEXT NOT NULL,
|
|
628
|
+
created_at INTEGER NOT NULL,
|
|
629
|
+
expires_at INTEGER NOT NULL,
|
|
630
|
+
last_seen INTEGER NOT NULL,
|
|
631
|
+
secret TEXT,
|
|
632
|
+
answerer_peer_id TEXT,
|
|
633
|
+
answer_sdp TEXT,
|
|
634
|
+
answered_at INTEGER
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
CREATE INDEX IF NOT EXISTS idx_offers_peer ON offers(peer_id);
|
|
638
|
+
CREATE INDEX IF NOT EXISTS idx_offers_expires ON offers(expires_at);
|
|
639
|
+
CREATE INDEX IF NOT EXISTS idx_offers_last_seen ON offers(last_seen);
|
|
640
|
+
CREATE INDEX IF NOT EXISTS idx_offers_answerer ON offers(answerer_peer_id);
|
|
641
|
+
|
|
642
|
+
CREATE TABLE IF NOT EXISTS offer_topics (
|
|
643
|
+
offer_id TEXT NOT NULL,
|
|
216
644
|
topic TEXT NOT NULL,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
645
|
+
PRIMARY KEY (offer_id, topic),
|
|
646
|
+
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
CREATE INDEX IF NOT EXISTS idx_topics_topic ON offer_topics(topic);
|
|
650
|
+
CREATE INDEX IF NOT EXISTS idx_topics_offer ON offer_topics(offer_id);
|
|
651
|
+
|
|
652
|
+
CREATE TABLE IF NOT EXISTS ice_candidates (
|
|
653
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
654
|
+
offer_id TEXT NOT NULL,
|
|
655
|
+
peer_id TEXT NOT NULL,
|
|
656
|
+
role TEXT NOT NULL CHECK(role IN ('offerer', 'answerer')),
|
|
657
|
+
candidate TEXT NOT NULL, -- JSON: RTCIceCandidateInit object
|
|
222
658
|
created_at INTEGER NOT NULL,
|
|
223
|
-
|
|
659
|
+
FOREIGN KEY (offer_id) REFERENCES offers(id) ON DELETE CASCADE
|
|
224
660
|
);
|
|
225
661
|
|
|
226
|
-
CREATE INDEX IF NOT EXISTS
|
|
227
|
-
CREATE INDEX IF NOT EXISTS
|
|
228
|
-
CREATE INDEX IF NOT EXISTS
|
|
662
|
+
CREATE INDEX IF NOT EXISTS idx_ice_offer ON ice_candidates(offer_id);
|
|
663
|
+
CREATE INDEX IF NOT EXISTS idx_ice_peer ON ice_candidates(peer_id);
|
|
664
|
+
CREATE INDEX IF NOT EXISTS idx_ice_created ON ice_candidates(created_at);
|
|
229
665
|
`);
|
|
666
|
+
this.db.pragma("foreign_keys = ON");
|
|
230
667
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const stmt = this.db.prepare(`
|
|
262
|
-
INSERT INTO sessions (code, origin, topic, info, offer, created_at, expires_at)
|
|
263
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
264
|
-
`);
|
|
265
|
-
stmt.run(code, origin, topic, info, offer, Date.now(), expiresAt);
|
|
266
|
-
break;
|
|
267
|
-
} catch (err) {
|
|
268
|
-
if (err.code === "SQLITE_CONSTRAINT_PRIMARYKEY") {
|
|
269
|
-
continue;
|
|
668
|
+
async createOffers(offers) {
|
|
669
|
+
const created = [];
|
|
670
|
+
const offersWithIds = await Promise.all(
|
|
671
|
+
offers.map(async (offer) => ({
|
|
672
|
+
...offer,
|
|
673
|
+
id: offer.id || await generateOfferHash(offer.sdp, offer.topics)
|
|
674
|
+
}))
|
|
675
|
+
);
|
|
676
|
+
const transaction = this.db.transaction((offersWithIds2) => {
|
|
677
|
+
const offerStmt = this.db.prepare(`
|
|
678
|
+
INSERT INTO offers (id, peer_id, sdp, created_at, expires_at, last_seen, secret)
|
|
679
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
680
|
+
`);
|
|
681
|
+
const topicStmt = this.db.prepare(`
|
|
682
|
+
INSERT INTO offer_topics (offer_id, topic)
|
|
683
|
+
VALUES (?, ?)
|
|
684
|
+
`);
|
|
685
|
+
for (const offer of offersWithIds2) {
|
|
686
|
+
const now = Date.now();
|
|
687
|
+
offerStmt.run(
|
|
688
|
+
offer.id,
|
|
689
|
+
offer.peerId,
|
|
690
|
+
offer.sdp,
|
|
691
|
+
now,
|
|
692
|
+
offer.expiresAt,
|
|
693
|
+
now,
|
|
694
|
+
offer.secret || null
|
|
695
|
+
);
|
|
696
|
+
for (const topic of offer.topics) {
|
|
697
|
+
topicStmt.run(offer.id, topic);
|
|
270
698
|
}
|
|
271
|
-
|
|
699
|
+
created.push({
|
|
700
|
+
id: offer.id,
|
|
701
|
+
peerId: offer.peerId,
|
|
702
|
+
sdp: offer.sdp,
|
|
703
|
+
topics: offer.topics,
|
|
704
|
+
createdAt: now,
|
|
705
|
+
expiresAt: offer.expiresAt,
|
|
706
|
+
lastSeen: now,
|
|
707
|
+
secret: offer.secret
|
|
708
|
+
});
|
|
272
709
|
}
|
|
273
|
-
}
|
|
274
|
-
|
|
710
|
+
});
|
|
711
|
+
transaction(offersWithIds);
|
|
712
|
+
return created;
|
|
275
713
|
}
|
|
276
|
-
async
|
|
277
|
-
|
|
278
|
-
SELECT
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
expiresAt: row.expires_at
|
|
294
|
-
}));
|
|
714
|
+
async getOffersByTopic(topic, excludePeerIds) {
|
|
715
|
+
let query = `
|
|
716
|
+
SELECT DISTINCT o.*
|
|
717
|
+
FROM offers o
|
|
718
|
+
INNER JOIN offer_topics ot ON o.id = ot.offer_id
|
|
719
|
+
WHERE ot.topic = ? AND o.expires_at > ?
|
|
720
|
+
`;
|
|
721
|
+
const params = [topic, Date.now()];
|
|
722
|
+
if (excludePeerIds && excludePeerIds.length > 0) {
|
|
723
|
+
const placeholders = excludePeerIds.map(() => "?").join(",");
|
|
724
|
+
query += ` AND o.peer_id NOT IN (${placeholders})`;
|
|
725
|
+
params.push(...excludePeerIds);
|
|
726
|
+
}
|
|
727
|
+
query += " ORDER BY o.last_seen DESC";
|
|
728
|
+
const stmt = this.db.prepare(query);
|
|
729
|
+
const rows = stmt.all(...params);
|
|
730
|
+
return Promise.all(rows.map((row) => this.rowToOffer(row)));
|
|
295
731
|
}
|
|
296
|
-
async
|
|
297
|
-
const safeLimit = Math.min(Math.max(1, limit), 1e3);
|
|
298
|
-
const safePage = Math.max(1, page);
|
|
299
|
-
const offset = (safePage - 1) * safeLimit;
|
|
300
|
-
const countStmt = this.db.prepare(`
|
|
301
|
-
SELECT COUNT(DISTINCT topic) as total
|
|
302
|
-
FROM sessions
|
|
303
|
-
WHERE origin = ? AND expires_at > ? AND answer IS NULL
|
|
304
|
-
`);
|
|
305
|
-
const { total } = countStmt.get(origin, Date.now());
|
|
732
|
+
async getOffersByPeerId(peerId) {
|
|
306
733
|
const stmt = this.db.prepare(`
|
|
307
|
-
SELECT
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
GROUP BY topic
|
|
311
|
-
ORDER BY topic ASC
|
|
312
|
-
LIMIT ? OFFSET ?
|
|
734
|
+
SELECT * FROM offers
|
|
735
|
+
WHERE peer_id = ? AND expires_at > ?
|
|
736
|
+
ORDER BY last_seen DESC
|
|
313
737
|
`);
|
|
314
|
-
const rows = stmt.all(
|
|
315
|
-
|
|
316
|
-
topic: row.topic,
|
|
317
|
-
count: row.count
|
|
318
|
-
}));
|
|
319
|
-
return {
|
|
320
|
-
topics,
|
|
321
|
-
pagination: {
|
|
322
|
-
page: safePage,
|
|
323
|
-
limit: safeLimit,
|
|
324
|
-
total,
|
|
325
|
-
hasMore: offset + topics.length < total
|
|
326
|
-
}
|
|
327
|
-
};
|
|
738
|
+
const rows = stmt.all(peerId, Date.now());
|
|
739
|
+
return Promise.all(rows.map((row) => this.rowToOffer(row)));
|
|
328
740
|
}
|
|
329
|
-
async
|
|
741
|
+
async getOfferById(offerId) {
|
|
330
742
|
const stmt = this.db.prepare(`
|
|
331
|
-
SELECT * FROM
|
|
743
|
+
SELECT * FROM offers
|
|
744
|
+
WHERE id = ? AND expires_at > ?
|
|
332
745
|
`);
|
|
333
|
-
const row = stmt.get(
|
|
746
|
+
const row = stmt.get(offerId, Date.now());
|
|
334
747
|
if (!row) {
|
|
335
748
|
return null;
|
|
336
749
|
}
|
|
337
|
-
return
|
|
338
|
-
code: row.code,
|
|
339
|
-
origin: row.origin,
|
|
340
|
-
topic: row.topic,
|
|
341
|
-
info: row.info,
|
|
342
|
-
offer: row.offer,
|
|
343
|
-
answer: row.answer || void 0,
|
|
344
|
-
offerCandidates: JSON.parse(row.offer_candidates),
|
|
345
|
-
answerCandidates: JSON.parse(row.answer_candidates),
|
|
346
|
-
createdAt: row.created_at,
|
|
347
|
-
expiresAt: row.expires_at
|
|
348
|
-
};
|
|
750
|
+
return this.rowToOffer(row);
|
|
349
751
|
}
|
|
350
|
-
async
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
752
|
+
async deleteOffer(offerId, ownerPeerId) {
|
|
753
|
+
const stmt = this.db.prepare(`
|
|
754
|
+
DELETE FROM offers
|
|
755
|
+
WHERE id = ? AND peer_id = ?
|
|
756
|
+
`);
|
|
757
|
+
const result = stmt.run(offerId, ownerPeerId);
|
|
758
|
+
return result.changes > 0;
|
|
759
|
+
}
|
|
760
|
+
async deleteExpiredOffers(now) {
|
|
761
|
+
const stmt = this.db.prepare("DELETE FROM offers WHERE expires_at < ?");
|
|
762
|
+
const result = stmt.run(now);
|
|
763
|
+
return result.changes;
|
|
764
|
+
}
|
|
765
|
+
async answerOffer(offerId, answererPeerId, answerSdp, secret) {
|
|
766
|
+
const offer = await this.getOfferById(offerId);
|
|
767
|
+
if (!offer) {
|
|
768
|
+
return {
|
|
769
|
+
success: false,
|
|
770
|
+
error: "Offer not found or expired"
|
|
771
|
+
};
|
|
360
772
|
}
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
773
|
+
if (offer.secret && offer.secret !== secret) {
|
|
774
|
+
return {
|
|
775
|
+
success: false,
|
|
776
|
+
error: "Invalid or missing secret"
|
|
777
|
+
};
|
|
364
778
|
}
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
779
|
+
if (offer.answererPeerId) {
|
|
780
|
+
return {
|
|
781
|
+
success: false,
|
|
782
|
+
error: "Offer already answered"
|
|
783
|
+
};
|
|
368
784
|
}
|
|
369
|
-
|
|
370
|
-
|
|
785
|
+
const stmt = this.db.prepare(`
|
|
786
|
+
UPDATE offers
|
|
787
|
+
SET answerer_peer_id = ?, answer_sdp = ?, answered_at = ?
|
|
788
|
+
WHERE id = ? AND answerer_peer_id IS NULL
|
|
789
|
+
`);
|
|
790
|
+
const result = stmt.run(answererPeerId, answerSdp, Date.now(), offerId);
|
|
791
|
+
if (result.changes === 0) {
|
|
792
|
+
return {
|
|
793
|
+
success: false,
|
|
794
|
+
error: "Offer already answered (race condition)"
|
|
795
|
+
};
|
|
371
796
|
}
|
|
372
|
-
|
|
373
|
-
|
|
797
|
+
return { success: true };
|
|
798
|
+
}
|
|
799
|
+
async getAnsweredOffers(offererPeerId) {
|
|
374
800
|
const stmt = this.db.prepare(`
|
|
375
|
-
|
|
801
|
+
SELECT * FROM offers
|
|
802
|
+
WHERE peer_id = ? AND answerer_peer_id IS NOT NULL AND expires_at > ?
|
|
803
|
+
ORDER BY answered_at DESC
|
|
376
804
|
`);
|
|
377
|
-
stmt.
|
|
805
|
+
const rows = stmt.all(offererPeerId, Date.now());
|
|
806
|
+
return Promise.all(rows.map((row) => this.rowToOffer(row)));
|
|
378
807
|
}
|
|
379
|
-
async
|
|
380
|
-
const stmt = this.db.prepare(
|
|
381
|
-
|
|
808
|
+
async addIceCandidates(offerId, peerId, role, candidates) {
|
|
809
|
+
const stmt = this.db.prepare(`
|
|
810
|
+
INSERT INTO ice_candidates (offer_id, peer_id, role, candidate, created_at)
|
|
811
|
+
VALUES (?, ?, ?, ?, ?)
|
|
812
|
+
`);
|
|
813
|
+
const baseTimestamp = Date.now();
|
|
814
|
+
const transaction = this.db.transaction((candidates2) => {
|
|
815
|
+
for (let i = 0; i < candidates2.length; i++) {
|
|
816
|
+
stmt.run(
|
|
817
|
+
offerId,
|
|
818
|
+
peerId,
|
|
819
|
+
role,
|
|
820
|
+
JSON.stringify(candidates2[i]),
|
|
821
|
+
// Store full object as JSON
|
|
822
|
+
baseTimestamp + i
|
|
823
|
+
// Ensure unique timestamps to avoid "since" filtering issues
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
transaction(candidates);
|
|
828
|
+
return candidates.length;
|
|
382
829
|
}
|
|
383
|
-
async
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
830
|
+
async getIceCandidates(offerId, targetRole, since) {
|
|
831
|
+
let query = `
|
|
832
|
+
SELECT * FROM ice_candidates
|
|
833
|
+
WHERE offer_id = ? AND role = ?
|
|
834
|
+
`;
|
|
835
|
+
const params = [offerId, targetRole];
|
|
836
|
+
if (since !== void 0) {
|
|
837
|
+
query += " AND created_at > ?";
|
|
838
|
+
params.push(since);
|
|
388
839
|
}
|
|
840
|
+
query += " ORDER BY created_at ASC";
|
|
841
|
+
const stmt = this.db.prepare(query);
|
|
842
|
+
const rows = stmt.all(...params);
|
|
843
|
+
return rows.map((row) => ({
|
|
844
|
+
id: row.id,
|
|
845
|
+
offerId: row.offer_id,
|
|
846
|
+
peerId: row.peer_id,
|
|
847
|
+
role: row.role,
|
|
848
|
+
candidate: JSON.parse(row.candidate),
|
|
849
|
+
// Parse JSON back to object
|
|
850
|
+
createdAt: row.created_at
|
|
851
|
+
}));
|
|
852
|
+
}
|
|
853
|
+
async getTopics(limit, offset, startsWith) {
|
|
854
|
+
const now = Date.now();
|
|
855
|
+
const whereClause = startsWith ? "o.expires_at > ? AND ot.topic LIKE ?" : "o.expires_at > ?";
|
|
856
|
+
const startsWithPattern = startsWith ? `${startsWith}%` : null;
|
|
857
|
+
const countQuery = `
|
|
858
|
+
SELECT COUNT(DISTINCT ot.topic) as count
|
|
859
|
+
FROM offer_topics ot
|
|
860
|
+
INNER JOIN offers o ON ot.offer_id = o.id
|
|
861
|
+
WHERE ${whereClause}
|
|
862
|
+
`;
|
|
863
|
+
const countStmt = this.db.prepare(countQuery);
|
|
864
|
+
const countParams = startsWith ? [now, startsWithPattern] : [now];
|
|
865
|
+
const countRow = countStmt.get(...countParams);
|
|
866
|
+
const total = countRow.count;
|
|
867
|
+
const topicsQuery = `
|
|
868
|
+
SELECT
|
|
869
|
+
ot.topic,
|
|
870
|
+
COUNT(DISTINCT o.peer_id) as active_peers
|
|
871
|
+
FROM offer_topics ot
|
|
872
|
+
INNER JOIN offers o ON ot.offer_id = o.id
|
|
873
|
+
WHERE ${whereClause}
|
|
874
|
+
GROUP BY ot.topic
|
|
875
|
+
ORDER BY active_peers DESC, ot.topic ASC
|
|
876
|
+
LIMIT ? OFFSET ?
|
|
877
|
+
`;
|
|
878
|
+
const topicsStmt = this.db.prepare(topicsQuery);
|
|
879
|
+
const topicsParams = startsWith ? [now, startsWithPattern, limit, offset] : [now, limit, offset];
|
|
880
|
+
const rows = topicsStmt.all(...topicsParams);
|
|
881
|
+
const topics = rows.map((row) => ({
|
|
882
|
+
topic: row.topic,
|
|
883
|
+
activePeers: row.active_peers
|
|
884
|
+
}));
|
|
885
|
+
return { topics, total };
|
|
389
886
|
}
|
|
390
887
|
async close() {
|
|
391
888
|
this.db.close();
|
|
392
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* Helper method to convert database row to Offer object with topics
|
|
892
|
+
*/
|
|
893
|
+
async rowToOffer(row) {
|
|
894
|
+
const topicStmt = this.db.prepare(`
|
|
895
|
+
SELECT topic FROM offer_topics WHERE offer_id = ?
|
|
896
|
+
`);
|
|
897
|
+
const topicRows = topicStmt.all(row.id);
|
|
898
|
+
const topics = topicRows.map((t) => t.topic);
|
|
899
|
+
return {
|
|
900
|
+
id: row.id,
|
|
901
|
+
peerId: row.peer_id,
|
|
902
|
+
sdp: row.sdp,
|
|
903
|
+
topics,
|
|
904
|
+
createdAt: row.created_at,
|
|
905
|
+
expiresAt: row.expires_at,
|
|
906
|
+
lastSeen: row.last_seen,
|
|
907
|
+
secret: row.secret || void 0,
|
|
908
|
+
answererPeerId: row.answerer_peer_id || void 0,
|
|
909
|
+
answerSdp: row.answer_sdp || void 0,
|
|
910
|
+
answeredAt: row.answered_at || void 0
|
|
911
|
+
};
|
|
912
|
+
}
|
|
393
913
|
};
|
|
394
914
|
|
|
395
915
|
// src/index.ts
|
|
@@ -400,8 +920,14 @@ async function main() {
|
|
|
400
920
|
port: config.port,
|
|
401
921
|
storageType: config.storageType,
|
|
402
922
|
storagePath: config.storagePath,
|
|
403
|
-
|
|
404
|
-
|
|
923
|
+
offerDefaultTtl: `${config.offerDefaultTtl}ms`,
|
|
924
|
+
offerMaxTtl: `${config.offerMaxTtl}ms`,
|
|
925
|
+
offerMinTtl: `${config.offerMinTtl}ms`,
|
|
926
|
+
cleanupInterval: `${config.cleanupInterval}ms`,
|
|
927
|
+
maxOffersPerRequest: config.maxOffersPerRequest,
|
|
928
|
+
maxTopicsPerOffer: config.maxTopicsPerOffer,
|
|
929
|
+
corsOrigins: config.corsOrigins,
|
|
930
|
+
version: config.version
|
|
405
931
|
});
|
|
406
932
|
let storage;
|
|
407
933
|
if (config.storageType === "sqlite") {
|
|
@@ -410,25 +936,32 @@ async function main() {
|
|
|
410
936
|
} else {
|
|
411
937
|
throw new Error("Unsupported storage type");
|
|
412
938
|
}
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
939
|
+
const cleanupInterval = setInterval(async () => {
|
|
940
|
+
try {
|
|
941
|
+
const now = Date.now();
|
|
942
|
+
const deleted = await storage.deleteExpiredOffers(now);
|
|
943
|
+
if (deleted > 0) {
|
|
944
|
+
console.log(`Cleanup: Deleted ${deleted} expired offer(s)`);
|
|
945
|
+
}
|
|
946
|
+
} catch (err) {
|
|
947
|
+
console.error("Cleanup error:", err);
|
|
948
|
+
}
|
|
949
|
+
}, config.cleanupInterval);
|
|
950
|
+
const app = createApp(storage, config);
|
|
417
951
|
const server = (0, import_node_server.serve)({
|
|
418
952
|
fetch: app.fetch,
|
|
419
953
|
port: config.port
|
|
420
954
|
});
|
|
421
955
|
console.log(`Server running on http://localhost:${config.port}`);
|
|
422
|
-
|
|
956
|
+
console.log("Ready to accept connections");
|
|
957
|
+
const shutdown = async () => {
|
|
423
958
|
console.log("\nShutting down gracefully...");
|
|
959
|
+
clearInterval(cleanupInterval);
|
|
424
960
|
await storage.close();
|
|
425
961
|
process.exit(0);
|
|
426
|
-
}
|
|
427
|
-
process.on("
|
|
428
|
-
|
|
429
|
-
await storage.close();
|
|
430
|
-
process.exit(0);
|
|
431
|
-
});
|
|
962
|
+
};
|
|
963
|
+
process.on("SIGINT", shutdown);
|
|
964
|
+
process.on("SIGTERM", shutdown);
|
|
432
965
|
}
|
|
433
966
|
main().catch((err) => {
|
|
434
967
|
console.error("Fatal error:", err);
|