@njinlabs/njin 0.10.2 → 0.11.0-beta.1
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/README.md +31 -0
- package/package.json +19 -4
- package/src/cli/build-assets.ts +4 -1
- package/src/cli/build.ts +47 -23
- package/src/cli/create.ts +35 -9
- package/src/cli/index.ts +2 -1
- package/src/cli/update.ts +93 -16
- package/src/config/module.ts +9 -0
- package/src/core/adapters/bun_filesystem.ts +13 -5
- package/src/core/adapters/s3.ts +18 -6
- package/src/core/admin_schema.ts +54 -0
- package/src/core/banner.ts +18 -4
- package/src/core/config.ts +49 -11
- package/src/core/helper.ts +4 -1
- package/src/core/html_page.ts +63 -0
- package/src/core/model/data_type/array.ts +16 -5
- package/src/core/model/data_type/boolean.ts +4 -1
- package/src/core/model/data_type/date.ts +4 -1
- package/src/core/model/data_type/email.ts +4 -1
- package/src/core/model/data_type/file.ts +17 -4
- package/src/core/model/data_type/multi_file.ts +9 -4
- package/src/core/model/data_type/numeric.ts +4 -1
- package/src/core/model/data_type/object.ts +8 -2
- package/src/core/model/data_type/relation.ts +21 -5
- package/src/core/model/data_type/relation_many.ts +24 -6
- package/src/core/model/data_type/richtext.ts +4 -1
- package/src/core/model/data_type/select.ts +17 -3
- package/src/core/model/data_type/text.ts +4 -1
- package/src/core/model/hooks.ts +42 -15
- package/src/core/model/index.ts +101 -37
- package/src/core/path_guard.ts +10 -3
- package/src/core/plugin.ts +8 -1
- package/src/core/public_url.ts +24 -0
- package/src/core/route.ts +2 -1
- package/src/core/vars/index.ts +22 -8
- package/src/core/worker.ts +22 -6
- package/src/models/file.ts +1 -1
- package/src/models/user.ts +1 -1
- package/src/modules/admin.ts +2 -2
- package/src/modules/analytics.ts +60 -25
- package/src/modules/api.ts +67 -95
- package/src/modules/auth.ts +25 -8
- package/src/modules/elysia.ts +1 -1
- package/src/modules/file.ts +50 -33
- package/src/modules/img.ts +33 -14
- package/src/modules/logger.ts +1 -1
- package/src/modules/mcp.ts +437 -0
- package/src/modules/mcp_token.ts +295 -0
- package/src/modules/mcp_upload.ts +313 -0
- package/src/modules/oauth.ts +550 -0
- package/src/modules/setup.ts +13 -5
- package/src/modules/surreal.ts +43 -13
- package/src/modules/users.ts +23 -5
- package/src/modules/vars.ts +1 -1
- package/src/modules/view.ts +88 -54
package/src/core/model/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import surreal from "../../modules/surreal";
|
|
2
1
|
import moment from "moment";
|
|
3
2
|
import { RecordId, Table, type Values } from "surrealdb";
|
|
4
|
-
import { z } from "zod";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import surreal from "../../modules/surreal";
|
|
5
5
|
import { runAfterHooks, runBeforeDestroyHooks, runBeforeHooks } from "./hooks";
|
|
6
6
|
|
|
7
7
|
// See the containment-boost comment in read()'s relevanceSelect below for why this exists and
|
|
@@ -53,18 +53,20 @@ export type ReadOptions = {
|
|
|
53
53
|
filters?: Record<string, FilterValue>;
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
|
|
57
56
|
// Whitelist of operators — unknown operators are silently dropped, never interpolated.
|
|
58
|
-
const OPERATORS: Record<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
$
|
|
63
|
-
$
|
|
64
|
-
$
|
|
65
|
-
$
|
|
57
|
+
const OPERATORS: Record<
|
|
58
|
+
FilterOperator,
|
|
59
|
+
(field: string, param: string) => string
|
|
60
|
+
> = {
|
|
61
|
+
$eq: (f, p) => `${f} = $${p}`,
|
|
62
|
+
$ne: (f, p) => `${f} != $${p}`,
|
|
63
|
+
$gt: (f, p) => `${f} > $${p}`,
|
|
64
|
+
$gte: (f, p) => `${f} >= $${p}`,
|
|
65
|
+
$lt: (f, p) => `${f} < $${p}`,
|
|
66
|
+
$lte: (f, p) => `${f} <= $${p}`,
|
|
67
|
+
$contains: (f, p) => `string::contains(string::lowercase(${f}), $${p})`,
|
|
66
68
|
$startsWith: (f, p) => `string::starts_with(string::lowercase(${f}), $${p})`,
|
|
67
|
-
$in:
|
|
69
|
+
$in: (f, p) => `${f} CONTAINS $${p}`,
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
const RELATION_RENDER_AS = ["relation", "multi_relation", "file", "multi_file"];
|
|
@@ -78,19 +80,31 @@ const schemaRegistry = new Map<string, z.ZodObject>();
|
|
|
78
80
|
|
|
79
81
|
export type ResolvedSearchField =
|
|
80
82
|
| { kind: "flat"; field: string }
|
|
81
|
-
| {
|
|
83
|
+
| {
|
|
84
|
+
kind: "nested";
|
|
85
|
+
local: string;
|
|
86
|
+
targetPrefix: string;
|
|
87
|
+
targetField: string;
|
|
88
|
+
multi: boolean;
|
|
89
|
+
};
|
|
82
90
|
|
|
83
91
|
// Parses searchFields entries into flat field names and single-hop nested
|
|
84
92
|
// "relationField.targetField" references, validating relation-family fields and
|
|
85
93
|
// (when the target model has already registered itself) the target field's existence
|
|
86
94
|
// synchronously — so a bad reference throws at model-definition time, not per-request.
|
|
87
|
-
export const resolveSearchPlan = (
|
|
95
|
+
export const resolveSearchPlan = (
|
|
96
|
+
schema: z.ZodObject,
|
|
97
|
+
searchFields: string[],
|
|
98
|
+
modelName: string,
|
|
99
|
+
): ResolvedSearchField[] => {
|
|
88
100
|
return searchFields.map((raw): ResolvedSearchField => {
|
|
89
101
|
const dot = raw.indexOf(".");
|
|
90
102
|
if (dot === -1) return { kind: "flat", field: raw };
|
|
91
103
|
|
|
92
104
|
if (raw.indexOf(".", dot + 1) !== -1) {
|
|
93
|
-
throw new Error(
|
|
105
|
+
throw new Error(
|
|
106
|
+
`Model "${modelName}": searchFields entry "${raw}" has more than one level of nesting — only a single relation hop is supported.`,
|
|
107
|
+
);
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
const local = raw.slice(0, dot);
|
|
@@ -98,13 +112,17 @@ export const resolveSearchPlan = (schema: z.ZodObject, searchFields: string[], m
|
|
|
98
112
|
const meta = (schema.shape[local] as z.ZodType | undefined)?.meta() as any;
|
|
99
113
|
|
|
100
114
|
if (!meta || !RELATION_RENDER_AS.includes(meta.renderAs)) {
|
|
101
|
-
throw new Error(
|
|
115
|
+
throw new Error(
|
|
116
|
+
`Model "${modelName}": searchFields entry "${raw}" references "${local}", which is not a relation/file field on this model's schema.`,
|
|
117
|
+
);
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
const targetPrefix = meta.model as string;
|
|
105
121
|
const targetSchema = schemaRegistry.get(targetPrefix);
|
|
106
122
|
if (targetSchema && !(targetField in targetSchema.shape)) {
|
|
107
|
-
throw new Error(
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Model "${modelName}": searchFields entry "${raw}" references field "${targetField}", which does not exist on model "${targetPrefix}".`,
|
|
125
|
+
);
|
|
108
126
|
}
|
|
109
127
|
|
|
110
128
|
return {
|
|
@@ -112,7 +130,8 @@ export const resolveSearchPlan = (schema: z.ZodObject, searchFields: string[], m
|
|
|
112
130
|
local,
|
|
113
131
|
targetPrefix,
|
|
114
132
|
targetField,
|
|
115
|
-
multi:
|
|
133
|
+
multi:
|
|
134
|
+
meta.renderAs === "multi_relation" || meta.renderAs === "multi_file",
|
|
116
135
|
};
|
|
117
136
|
});
|
|
118
137
|
};
|
|
@@ -145,7 +164,11 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
145
164
|
|
|
146
165
|
const relationFieldSet = new Set(relationFields);
|
|
147
166
|
|
|
148
|
-
const searchPlan = resolveSearchPlan(
|
|
167
|
+
const searchPlan = resolveSearchPlan(
|
|
168
|
+
config.schema,
|
|
169
|
+
config.searchFields,
|
|
170
|
+
config.name,
|
|
171
|
+
);
|
|
149
172
|
|
|
150
173
|
const uniqueFields = Object.entries(config.schema.shape)
|
|
151
174
|
.filter(([, v]) => (v as z.ZodType).meta()?.unique === true)
|
|
@@ -161,19 +184,29 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
161
184
|
const filterableFieldSet = new Set(Object.keys(config.schema.shape));
|
|
162
185
|
|
|
163
186
|
// field must be a known unique field — prevents arbitrary field injection
|
|
164
|
-
const isDuplicate = async (
|
|
187
|
+
const isDuplicate = async (
|
|
188
|
+
field: string,
|
|
189
|
+
value: unknown,
|
|
190
|
+
excludeId?: string,
|
|
191
|
+
) => {
|
|
165
192
|
if (!uniqueFieldSet.has(field)) return false;
|
|
166
193
|
|
|
167
194
|
const excludeClause = excludeId ? "AND id != $excludeId" : "";
|
|
168
195
|
const [[row]] = await surreal().query<[{ count: number }[]]>(
|
|
169
196
|
`SELECT count() AS count FROM ${prefix} WHERE ${field} = $value ${excludeClause} GROUP ALL`,
|
|
170
|
-
{
|
|
197
|
+
{
|
|
198
|
+
value,
|
|
199
|
+
excludeId: excludeId ? new RecordId(table, excludeId) : undefined,
|
|
200
|
+
},
|
|
171
201
|
);
|
|
172
202
|
|
|
173
203
|
return (row?.count ?? 0) > 0;
|
|
174
204
|
};
|
|
175
205
|
|
|
176
|
-
const assertUnique = async (
|
|
206
|
+
const assertUnique = async (
|
|
207
|
+
data: Record<string, unknown>,
|
|
208
|
+
excludeId?: string,
|
|
209
|
+
) => {
|
|
177
210
|
for (const field of uniqueFields) {
|
|
178
211
|
const value = data[field];
|
|
179
212
|
if (value === undefined) continue; // partial update without this field — nothing to check
|
|
@@ -185,13 +218,22 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
185
218
|
};
|
|
186
219
|
|
|
187
220
|
const create = async (data: Values<Data>) => {
|
|
188
|
-
const merged = (await runBeforeHooks(
|
|
221
|
+
const merged = (await runBeforeHooks(
|
|
222
|
+
"beforeCreate",
|
|
223
|
+
prefix,
|
|
224
|
+
data as Record<string, unknown>,
|
|
225
|
+
{},
|
|
226
|
+
)) as Values<Data>;
|
|
189
227
|
|
|
190
228
|
await assertUnique(merged);
|
|
191
229
|
|
|
192
230
|
const record = (await surreal()
|
|
193
231
|
.create<Data>(table)
|
|
194
|
-
.content({
|
|
232
|
+
.content({
|
|
233
|
+
...merged,
|
|
234
|
+
createdAt: moment().toISOString(),
|
|
235
|
+
updatedAt: moment().toISOString(),
|
|
236
|
+
})
|
|
195
237
|
.output("after")
|
|
196
238
|
.then(([data]) => data)) as Returning;
|
|
197
239
|
|
|
@@ -246,7 +288,10 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
246
288
|
whereParts.push(`${key} = $f_${key}`);
|
|
247
289
|
} else {
|
|
248
290
|
// Operator form: filters[field][$op]=value
|
|
249
|
-
for (const [op, opValue] of Object.entries(value) as [
|
|
291
|
+
for (const [op, opValue] of Object.entries(value) as [
|
|
292
|
+
FilterOperator,
|
|
293
|
+
string,
|
|
294
|
+
][]) {
|
|
250
295
|
const builder = OPERATORS[op]; // strict whitelist — unknown ops get undefined
|
|
251
296
|
if (!builder || opValue === undefined) continue;
|
|
252
297
|
|
|
@@ -264,7 +309,12 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
264
309
|
const where = whereParts.length ? `WHERE ${whereParts.join(" AND ")}` : "";
|
|
265
310
|
// id/createdAt/updatedAt are always present on every record but aren't part of the
|
|
266
311
|
// user-defined schema shape (they're injected in create()/update()) — allow sorting by them too.
|
|
267
|
-
const sortableFields = new Set([
|
|
312
|
+
const sortableFields = new Set([
|
|
313
|
+
...Object.keys(config.schema.shape),
|
|
314
|
+
"id",
|
|
315
|
+
"createdAt",
|
|
316
|
+
"updatedAt",
|
|
317
|
+
]);
|
|
268
318
|
const hasExplicitSort = Boolean(sort && sortableFields.has(sort));
|
|
269
319
|
// An explicit sort always wins; otherwise, when searching, rank by relevance instead of
|
|
270
320
|
// leaving result order unspecified. `ORDER BY` only accepts a bare identifier here, not a
|
|
@@ -296,7 +346,8 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
296
346
|
// data routinely writes a multi-word term as one run-together token (e.g. "REDWING 2415..."
|
|
297
347
|
// for "Red Wing") — a plain substring check against "red wing" (with the space) would miss
|
|
298
348
|
// that despite it being a stronger match than most ngram overlaps.
|
|
299
|
-
const useRelevance =
|
|
349
|
+
const useRelevance =
|
|
350
|
+
!hasExplicitSort && Boolean(search && searchPlan.length);
|
|
300
351
|
const orderBy = hasExplicitSort
|
|
301
352
|
? `ORDER BY ${sort} ${order === "desc" ? "DESC" : "ASC"}`
|
|
302
353
|
: useRelevance
|
|
@@ -308,11 +359,14 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
308
359
|
? `, (${searchPlan
|
|
309
360
|
.map((e, i) => {
|
|
310
361
|
const n = i + 1;
|
|
311
|
-
const boost = (field: string) =>
|
|
362
|
+
const boost = (field: string) =>
|
|
363
|
+
`(IF ${containmentCheck(field)} THEN ${CONTAINMENT_BOOST} ELSE 0 END)`;
|
|
312
364
|
if (e.kind === "flat") {
|
|
313
365
|
return `(search::score(${n}) + ${boost(e.field)})`;
|
|
314
366
|
}
|
|
315
|
-
const idFilter = e.multi
|
|
367
|
+
const idFilter = e.multi
|
|
368
|
+
? `id IN $parent.${e.local}`
|
|
369
|
+
: `id = $parent.${e.local}`;
|
|
316
370
|
return `math::sum((SELECT VALUE (search::score(${n}) + ${boost(e.targetField)}) FROM ${e.targetPrefix} WHERE ${idFilter} AND ${e.targetField} @${n}@ $search))`;
|
|
317
371
|
})
|
|
318
372
|
.join(" + ")}) AS __relevance`
|
|
@@ -328,7 +382,9 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
328
382
|
const fetch = fetchFields.length ? `FETCH ${fetchFields.join(", ")}` : "";
|
|
329
383
|
const start = (page - 1) * pageLimit;
|
|
330
384
|
|
|
331
|
-
const [rows, [countRow]] = await surreal().query<
|
|
385
|
+
const [rows, [countRow]] = await surreal().query<
|
|
386
|
+
[(Returning & { __relevance?: number })[], { count: number }[]]
|
|
387
|
+
>(
|
|
332
388
|
`SELECT *${relevanceSelect} FROM ${prefix} ${where} ${orderBy} LIMIT ${pageLimit} START ${start} ${fetch};
|
|
333
389
|
SELECT count() as count FROM ${prefix} ${where} GROUP ALL`,
|
|
334
390
|
params,
|
|
@@ -355,14 +411,20 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
355
411
|
|
|
356
412
|
const show = (id: string) => {
|
|
357
413
|
let q = surreal().select<Data>(new RecordId(table, id));
|
|
358
|
-
if (relationFields.length)
|
|
414
|
+
if (relationFields.length)
|
|
415
|
+
q = q.fetch(...(relationFields as [string, ...string[]])) as typeof q;
|
|
359
416
|
return q as unknown as Promise<Returning>;
|
|
360
417
|
};
|
|
361
418
|
|
|
362
419
|
const update = async (id: string, data: Values<Partial<Data>>) => {
|
|
363
|
-
const merged = (await runBeforeHooks(
|
|
364
|
-
|
|
365
|
-
|
|
420
|
+
const merged = (await runBeforeHooks(
|
|
421
|
+
"beforeUpdate",
|
|
422
|
+
prefix,
|
|
423
|
+
data as Record<string, unknown>,
|
|
424
|
+
{
|
|
425
|
+
id,
|
|
426
|
+
},
|
|
427
|
+
)) as Values<Partial<Data>>;
|
|
366
428
|
|
|
367
429
|
await assertUnique(merged, id);
|
|
368
430
|
|
|
@@ -381,7 +443,9 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
381
443
|
const destroy = async (id: string) => {
|
|
382
444
|
await runBeforeDestroyHooks(prefix, id);
|
|
383
445
|
|
|
384
|
-
const record = (await (surreal().delete<Data>(
|
|
446
|
+
const record = (await (surreal().delete<Data>(
|
|
447
|
+
new RecordId(table, id),
|
|
448
|
+
) as unknown as Promise<Returning>)) as Returning;
|
|
385
449
|
|
|
386
450
|
await runAfterHooks("afterDestroy", prefix, record);
|
|
387
451
|
|
|
@@ -402,10 +466,10 @@ export const makeModel = <Rules extends z.ZodObject>(
|
|
|
402
466
|
};
|
|
403
467
|
};
|
|
404
468
|
|
|
405
|
-
export * from "./data_type";
|
|
406
|
-
export * from "./hooks";
|
|
407
469
|
export * from "../event";
|
|
408
470
|
export * from "../helper";
|
|
409
471
|
export * from "../plugin";
|
|
410
472
|
export * from "../route";
|
|
411
473
|
export * from "../vars";
|
|
474
|
+
export * from "./data_type";
|
|
475
|
+
export * from "./hooks";
|
package/src/core/path_guard.ts
CHANGED
|
@@ -3,14 +3,21 @@ import { basename, join, normalize, sep } from "node:path";
|
|
|
3
3
|
// Resolves `requestedPath` against `baseDir` and rejects anything that escapes it
|
|
4
4
|
// (e.g. "../../etc/passwd") — shared by every module that serves files straight
|
|
5
5
|
// off disk based on a request path (uploads, admin panel SPA, etc).
|
|
6
|
-
export const resolveSafePath = (
|
|
6
|
+
export const resolveSafePath = (
|
|
7
|
+
baseDir: string,
|
|
8
|
+
requestedPath: string,
|
|
9
|
+
): string | null => {
|
|
7
10
|
// A bare `startsWith` prefix check would let "/app/uploads-evil" pass for a
|
|
8
11
|
// baseDir of "/app/uploads" — anchor the comparison to a directory boundary
|
|
9
12
|
// instead so only baseDir itself (or a path inside it) matches.
|
|
10
13
|
const normalizedBase = normalize(baseDir);
|
|
11
|
-
const boundary = normalizedBase.endsWith(sep)
|
|
14
|
+
const boundary = normalizedBase.endsWith(sep)
|
|
15
|
+
? normalizedBase
|
|
16
|
+
: normalizedBase + sep;
|
|
12
17
|
const resolved = normalize(join(baseDir, requestedPath));
|
|
13
|
-
return resolved === normalizedBase || resolved.startsWith(boundary)
|
|
18
|
+
return resolved === normalizedBase || resolved.startsWith(boundary)
|
|
19
|
+
? resolved
|
|
20
|
+
: null;
|
|
14
21
|
};
|
|
15
22
|
|
|
16
23
|
// Strips any directory component from a client-supplied filename (e.g. a multipart
|
package/src/core/plugin.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
EventFactory,
|
|
3
|
+
HelperFactory,
|
|
4
|
+
HookFactory,
|
|
5
|
+
ModelFactory,
|
|
6
|
+
RouteFactory,
|
|
7
|
+
VarsFactory,
|
|
8
|
+
} from "./config";
|
|
2
9
|
|
|
3
10
|
export type Plugin = {
|
|
4
11
|
models?: ModelFactory[];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { getConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
// The base URL clients actually reach the site on. config.publicUrl wins; otherwise it is
|
|
4
|
+
// derived from the request, honouring a reverse proxy's X-Forwarded-* headers — those are
|
|
5
|
+
// only trusted for building discovery documents, never for any access decision.
|
|
6
|
+
export const publicBase = (request: Request) => {
|
|
7
|
+
const configured = getConfig().publicUrl;
|
|
8
|
+
if (configured) {
|
|
9
|
+
// Not a /\/+$/ regex — that is quadratic on a long run of slashes.
|
|
10
|
+
let base = configured;
|
|
11
|
+
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
12
|
+
return base;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const url = new URL(request.url);
|
|
16
|
+
const first = (name: string) =>
|
|
17
|
+
request.headers.get(name)?.split(",")[0]?.trim();
|
|
18
|
+
|
|
19
|
+
const proto = first("x-forwarded-proto") ?? url.protocol.replace(":", "");
|
|
20
|
+
const host =
|
|
21
|
+
first("x-forwarded-host") ?? request.headers.get("host") ?? url.host;
|
|
22
|
+
|
|
23
|
+
return `${proto}://${host}`;
|
|
24
|
+
};
|
package/src/core/route.ts
CHANGED
|
@@ -3,4 +3,5 @@ import { Elysia } from "elysia";
|
|
|
3
3
|
// Thin factory over Elysia itself — purely for DX (autocomplete/type-checking),
|
|
4
4
|
// same purpose as defineConfig. Custom route files get the full Elysia API
|
|
5
5
|
// (schema validation, guards, groups, macros, ...) with no wrapping layer.
|
|
6
|
-
export const route = (config?: ConstructorParameters<typeof Elysia>[0]) =>
|
|
6
|
+
export const route = (config?: ConstructorParameters<typeof Elysia>[0]) =>
|
|
7
|
+
new Elysia(config);
|
package/src/core/vars/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import surreal from "../../modules/surreal";
|
|
2
1
|
import moment from "moment";
|
|
3
2
|
import { RecordId, Table, type Values } from "surrealdb";
|
|
4
|
-
import { z } from "zod";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import surreal from "../../modules/surreal";
|
|
5
5
|
import { runAfterHooks, runBeforeHooks } from "../model/hooks";
|
|
6
6
|
|
|
7
7
|
// One shared physical table for every VARS group — rows are keyed by each
|
|
@@ -26,9 +26,16 @@ export const makeVars = <Rules extends z.ZodObject>(
|
|
|
26
26
|
// returns a complete, valid object. Every VARS field is expected to
|
|
27
27
|
// declare a .default(...) for this reason.
|
|
28
28
|
const get = async (): Promise<Returning> => {
|
|
29
|
-
const record = await surreal().select<Data & { updatedAt?: string }>(
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
const record = await surreal().select<Data & { updatedAt?: string }>(
|
|
30
|
+
recordId,
|
|
31
|
+
);
|
|
32
|
+
const { updatedAt, ...rest } = (record ?? {}) as Record<string, unknown> & {
|
|
33
|
+
updatedAt?: string;
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
...(config.schema.parse(rest) as Data),
|
|
37
|
+
updatedAt: updatedAt ?? null,
|
|
38
|
+
};
|
|
32
39
|
};
|
|
33
40
|
|
|
34
41
|
// Upsert, not update — the row may not exist yet on first save.
|
|
@@ -42,11 +49,18 @@ export const makeVars = <Rules extends z.ZodObject>(
|
|
|
42
49
|
|
|
43
50
|
const record = (await surreal()
|
|
44
51
|
.upsert<Data & { updatedAt: string }>(recordId)
|
|
45
|
-
.merge({ ...merged, updatedAt: moment().toISOString() } as Values<
|
|
52
|
+
.merge({ ...merged, updatedAt: moment().toISOString() } as Values<
|
|
53
|
+
Data & { updatedAt: string }
|
|
54
|
+
>)
|
|
46
55
|
.output("after")) as (Data & { updatedAt: string }) | undefined;
|
|
47
56
|
|
|
48
|
-
const { updatedAt, ...rest } = (record ?? {}) as Record<string, unknown> & {
|
|
49
|
-
|
|
57
|
+
const { updatedAt, ...rest } = (record ?? {}) as Record<string, unknown> & {
|
|
58
|
+
updatedAt?: string;
|
|
59
|
+
};
|
|
60
|
+
const result = {
|
|
61
|
+
...(config.schema.parse(rest) as Data),
|
|
62
|
+
updatedAt: updatedAt ?? null,
|
|
63
|
+
};
|
|
50
64
|
|
|
51
65
|
await runAfterHooks("afterVarsUpdate", prefix, result);
|
|
52
66
|
|
package/src/core/worker.ts
CHANGED
|
@@ -45,7 +45,10 @@ export type WorkerReadyMessage = {
|
|
|
45
45
|
type: "ready";
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
export type WorkerOutboundMessage =
|
|
48
|
+
export type WorkerOutboundMessage =
|
|
49
|
+
| WorkerResponseMessage
|
|
50
|
+
| WorkerErrorMessage
|
|
51
|
+
| WorkerReadyMessage;
|
|
49
52
|
|
|
50
53
|
// Bounds how long shutdown waits for in-flight requests to finish before exiting anyway —
|
|
51
54
|
// the supervisor can terminate a worker at any time, so this is a safety net, not the
|
|
@@ -54,14 +57,21 @@ const SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
|
|
|
54
57
|
const SHUTDOWN_POLL_INTERVAL_MS = 50;
|
|
55
58
|
|
|
56
59
|
const isRequestMessage = (raw: unknown): raw is WorkerRequestMessage =>
|
|
57
|
-
typeof raw === "object" &&
|
|
60
|
+
typeof raw === "object" &&
|
|
61
|
+
raw !== null &&
|
|
62
|
+
(raw as { type?: unknown }).type === "request";
|
|
58
63
|
|
|
59
64
|
const isShutdownMessage = (raw: unknown): raw is WorkerShutdownMessage =>
|
|
60
|
-
typeof raw === "object" &&
|
|
65
|
+
typeof raw === "object" &&
|
|
66
|
+
raw !== null &&
|
|
67
|
+
(raw as { type?: unknown }).type === "shutdown";
|
|
61
68
|
|
|
62
69
|
// Pure request/response conversion, split out from serveWorker()'s self/postMessage wiring
|
|
63
70
|
// so it can be unit tested directly without spinning up a real Worker thread.
|
|
64
|
-
export const buildResponseMessage = async (
|
|
71
|
+
export const buildResponseMessage = async (
|
|
72
|
+
app: AnyElysia,
|
|
73
|
+
msg: WorkerRequestMessage,
|
|
74
|
+
): Promise<WorkerResponseMessage> => {
|
|
65
75
|
const request = new Request(msg.url, {
|
|
66
76
|
method: msg.method,
|
|
67
77
|
headers: msg.headers,
|
|
@@ -90,7 +100,9 @@ export const buildResponseMessage = async (app: AnyElysia, msg: WorkerRequestMes
|
|
|
90
100
|
// real socket connections; app.handle() is the same Elysia entrypoint either way.
|
|
91
101
|
export const serveWorker = (app: AnyElysia): void => {
|
|
92
102
|
if (Bun.isMainThread) {
|
|
93
|
-
throw new Error(
|
|
103
|
+
throw new Error(
|
|
104
|
+
'serveWorker() must run inside a Worker thread (new Worker("out/worker.js")), not the main thread.',
|
|
105
|
+
);
|
|
94
106
|
}
|
|
95
107
|
|
|
96
108
|
let inFlight = 0;
|
|
@@ -98,7 +110,11 @@ export const serveWorker = (app: AnyElysia): void => {
|
|
|
98
110
|
|
|
99
111
|
const handleRequest = async (msg: WorkerRequestMessage): Promise<void> => {
|
|
100
112
|
if (shuttingDown) {
|
|
101
|
-
self.postMessage({
|
|
113
|
+
self.postMessage({
|
|
114
|
+
type: "error",
|
|
115
|
+
id: msg.id,
|
|
116
|
+
message: "worker is shutting down",
|
|
117
|
+
} satisfies WorkerErrorMessage);
|
|
102
118
|
return;
|
|
103
119
|
}
|
|
104
120
|
|
package/src/models/file.ts
CHANGED
package/src/models/user.ts
CHANGED
package/src/modules/admin.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import Elysia from "elysia";
|
|
1
3
|
import { getConfig } from "../core/config";
|
|
2
4
|
import { makeModule } from "../core/module";
|
|
3
5
|
import { resolveSafePath } from "../core/path_guard";
|
|
4
|
-
import Elysia from "elysia";
|
|
5
|
-
import { join } from "node:path";
|
|
6
6
|
import elysia from "./elysia";
|
|
7
7
|
|
|
8
8
|
// Lazy, not a top-level const — this file's static import runs before module.ts's own
|
package/src/modules/analytics.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { makeModule } from "../core/module";
|
|
2
1
|
import Elysia from "elysia";
|
|
3
2
|
import geoip from "geoip-lite";
|
|
4
3
|
import moment from "moment";
|
|
5
4
|
import { Table } from "surrealdb";
|
|
6
5
|
import z from "zod";
|
|
6
|
+
import { makeModule } from "../core/module";
|
|
7
7
|
import auth from "./auth";
|
|
8
8
|
import elysia from "./elysia";
|
|
9
9
|
import logger from "./logger";
|
|
@@ -46,9 +46,27 @@ export const resolveClientIp = (
|
|
|
46
46
|
return server?.requestIP(request)?.address ?? null;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
// `request.url` reflects what the backend itself sees — behind a reverse proxy that
|
|
50
|
+
// terminates TLS (the common case), that's `http://` even though visitors hit the site
|
|
51
|
+
// over `https://`. That scheme mismatch alone breaks isSameOrigin's exact comparison,
|
|
52
|
+
// so every internal navigation gets misclassified as an external referrer. Prefer the
|
|
53
|
+
// headers a trusted proxy sets; fall back to request.url for direct connections.
|
|
54
|
+
export const resolveRequestOrigin = (request: Request) => {
|
|
55
|
+
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
56
|
+
if (forwardedHost) {
|
|
57
|
+
const proto =
|
|
58
|
+
request.headers.get("x-forwarded-proto")?.split(",")[0]!.trim() || "http";
|
|
59
|
+
return `${proto}://${forwardedHost.split(",")[0]!.trim()}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new URL(request.url).origin;
|
|
63
|
+
};
|
|
64
|
+
|
|
49
65
|
const hashVisitor = (ip: string, userAgent: string) => {
|
|
50
66
|
const today = moment().format("YYYY-MM-DD");
|
|
51
|
-
return new Bun.CryptoHasher("sha256")
|
|
67
|
+
return new Bun.CryptoHasher("sha256")
|
|
68
|
+
.update(`${ip}:${userAgent}:${today}`)
|
|
69
|
+
.digest("hex");
|
|
52
70
|
};
|
|
53
71
|
|
|
54
72
|
const lookupCountry = (ip: string | null) => {
|
|
@@ -87,22 +105,28 @@ const buildWhere = (from?: string, to?: string, path?: string) => {
|
|
|
87
105
|
|
|
88
106
|
const analytics = makeModule(() => {
|
|
89
107
|
const fn = () => ({
|
|
90
|
-
track: async ({
|
|
108
|
+
track: async ({
|
|
109
|
+
path,
|
|
110
|
+
referrer,
|
|
111
|
+
userAgent,
|
|
112
|
+
ip,
|
|
113
|
+
requestUrl,
|
|
114
|
+
}: TrackInput) => {
|
|
91
115
|
try {
|
|
92
116
|
const country = lookupCountry(ip);
|
|
93
117
|
const visitorHash = ip && userAgent ? hashVisitor(ip, userAgent) : null;
|
|
94
|
-
const externalReferrer = isSameOrigin(referrer, requestUrl)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
118
|
+
const externalReferrer = isSameOrigin(referrer, requestUrl)
|
|
119
|
+
? null
|
|
120
|
+
: referrer;
|
|
121
|
+
|
|
122
|
+
await surreal().create(table).content({
|
|
123
|
+
path,
|
|
124
|
+
referrer: externalReferrer,
|
|
125
|
+
userAgent,
|
|
126
|
+
country,
|
|
127
|
+
visitorHash,
|
|
128
|
+
createdAt: moment().toISOString(),
|
|
129
|
+
});
|
|
106
130
|
} catch (e) {
|
|
107
131
|
logger().error(e, "[analytics] failed to track pageview");
|
|
108
132
|
}
|
|
@@ -118,13 +142,14 @@ const analytics = makeModule(() => {
|
|
|
118
142
|
"/summary",
|
|
119
143
|
async ({ query: { from, to, path } }) => {
|
|
120
144
|
const { where, params } = buildWhere(from, to, path);
|
|
121
|
-
const [rows] = await surreal().query<
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
);
|
|
145
|
+
const [rows] = await surreal().query<
|
|
146
|
+
[{ visitorHash: string | null }[]]
|
|
147
|
+
>(`SELECT visitorHash FROM pageview ${where}`, params);
|
|
125
148
|
|
|
126
149
|
const totalPageviews = rows?.length ?? 0;
|
|
127
|
-
const uniqueVisitors = new Set(
|
|
150
|
+
const uniqueVisitors = new Set(
|
|
151
|
+
(rows ?? []).map((r) => r.visitorHash).filter(Boolean),
|
|
152
|
+
).size;
|
|
128
153
|
|
|
129
154
|
return { data: { totalPageviews, uniqueVisitors } };
|
|
130
155
|
},
|
|
@@ -134,7 +159,9 @@ const analytics = makeModule(() => {
|
|
|
134
159
|
"/by-country",
|
|
135
160
|
async ({ query: { from, to, path } }) => {
|
|
136
161
|
const { where, params } = buildWhere(from, to, path);
|
|
137
|
-
const [rows] = await surreal().query<
|
|
162
|
+
const [rows] = await surreal().query<
|
|
163
|
+
[{ country: string | null; count: number }[]]
|
|
164
|
+
>(
|
|
138
165
|
`SELECT country, count() AS count FROM pageview ${where} GROUP BY country ORDER BY count DESC`,
|
|
139
166
|
params,
|
|
140
167
|
);
|
|
@@ -147,7 +174,9 @@ const analytics = makeModule(() => {
|
|
|
147
174
|
"/by-referrer",
|
|
148
175
|
async ({ query: { from, to, path } }) => {
|
|
149
176
|
const { where, params } = buildWhere(from, to, path);
|
|
150
|
-
const [rows] = await surreal().query<
|
|
177
|
+
const [rows] = await surreal().query<
|
|
178
|
+
[{ referrer: string | null; count: number }[]]
|
|
179
|
+
>(
|
|
151
180
|
`SELECT referrer, count() AS count FROM pageview ${where} GROUP BY referrer ORDER BY count DESC`,
|
|
152
181
|
params,
|
|
153
182
|
);
|
|
@@ -160,7 +189,9 @@ const analytics = makeModule(() => {
|
|
|
160
189
|
"/by-page",
|
|
161
190
|
async ({ query: { from, to } }) => {
|
|
162
191
|
const { where, params } = buildWhere(from, to, undefined);
|
|
163
|
-
const [rows] = await surreal().query<
|
|
192
|
+
const [rows] = await surreal().query<
|
|
193
|
+
[{ path: string; count: number }[]]
|
|
194
|
+
>(
|
|
164
195
|
`SELECT path, count() AS count FROM pageview ${where} GROUP BY path ORDER BY count DESC`,
|
|
165
196
|
params,
|
|
166
197
|
);
|
|
@@ -178,7 +209,9 @@ const analytics = makeModule(() => {
|
|
|
178
209
|
const { where, params } = buildWhere(from, to, path);
|
|
179
210
|
const bucketLen = interval === "hour" ? 13 : 10; // "YYYY-MM-DDTHH" vs "YYYY-MM-DD"
|
|
180
211
|
|
|
181
|
-
const [rows] = await surreal().query<
|
|
212
|
+
const [rows] = await surreal().query<
|
|
213
|
+
[{ date: string; count: number }[]]
|
|
214
|
+
>(
|
|
182
215
|
`SELECT string::slice(createdAt, 0, ${bucketLen}) AS date, count() AS count
|
|
183
216
|
FROM pageview ${where}
|
|
184
217
|
GROUP BY date
|
|
@@ -201,7 +234,9 @@ const analytics = makeModule(() => {
|
|
|
201
234
|
return {
|
|
202
235
|
// Runs after surreal's own spin() has connected — init() itself runs too early for DB queries.
|
|
203
236
|
spin: async () => {
|
|
204
|
-
await surreal().query(
|
|
237
|
+
await surreal().query(
|
|
238
|
+
"DEFINE TABLE IF NOT EXISTS pageview SCHEMALESS;",
|
|
239
|
+
);
|
|
205
240
|
},
|
|
206
241
|
};
|
|
207
242
|
};
|