@njinlabs/njin 0.1.0
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/LICENSE +21 -0
- package/README.md +223 -0
- package/package.json +59 -0
- package/src/cli/build.ts +162 -0
- package/src/cli/dev.ts +11 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/start.ts +9 -0
- package/src/config/module.ts +34 -0
- package/src/core/adapters/bun_filesystem.ts +37 -0
- package/src/core/adapters/s3.ts +60 -0
- package/src/core/banner.ts +54 -0
- package/src/core/boot.ts +10 -0
- package/src/core/config.ts +89 -0
- package/src/core/http_error.ts +21 -0
- package/src/core/model/data_type/array.ts +22 -0
- package/src/core/model/data_type/boolean.ts +15 -0
- package/src/core/model/data_type/date.ts +28 -0
- package/src/core/model/data_type/email.ts +21 -0
- package/src/core/model/data_type/file.ts +15 -0
- package/src/core/model/data_type/index.ts +13 -0
- package/src/core/model/data_type/multi_file.ts +18 -0
- package/src/core/model/data_type/numeric.ts +15 -0
- package/src/core/model/data_type/object.ts +15 -0
- package/src/core/model/data_type/relation.ts +73 -0
- package/src/core/model/data_type/relation_many.ts +55 -0
- package/src/core/model/data_type/richtext.ts +20 -0
- package/src/core/model/data_type/select.ts +15 -0
- package/src/core/model/data_type/text.ts +20 -0
- package/src/core/model/index.ts +244 -0
- package/src/core/module/index.ts +12 -0
- package/src/core/path_guard.ts +9 -0
- package/src/models/file.ts +17 -0
- package/src/models/user.ts +18 -0
- package/src/modules/admin.ts +43 -0
- package/src/modules/analytics.ts +212 -0
- package/src/modules/api.ts +160 -0
- package/src/modules/auth.ts +153 -0
- package/src/modules/elysia.ts +85 -0
- package/src/modules/file.ts +131 -0
- package/src/modules/img.ts +137 -0
- package/src/modules/logger.ts +18 -0
- package/src/modules/setup.ts +89 -0
- package/src/modules/surreal.ts +58 -0
- package/src/modules/users.ts +94 -0
- package/src/modules/view.ts +247 -0
- package/src/spin.ts +3 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import surreal from "../../modules/surreal";
|
|
2
|
+
import moment from "moment";
|
|
3
|
+
import { RecordId, Table, type Values } from "surrealdb";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
export type FormMeta = {
|
|
7
|
+
label: string;
|
|
8
|
+
unique?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export class UniqueConstraintError extends Error {
|
|
12
|
+
constructor(
|
|
13
|
+
public readonly field: string,
|
|
14
|
+
public readonly value: unknown,
|
|
15
|
+
) {
|
|
16
|
+
super(`Field "${field}" must be unique — value already exists`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type ReadMeta = {
|
|
21
|
+
total: number;
|
|
22
|
+
page: number;
|
|
23
|
+
limit: number;
|
|
24
|
+
pageCount: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type FilterValue = string | Partial<Record<FilterOperator, string>>;
|
|
28
|
+
|
|
29
|
+
export type FilterOperator =
|
|
30
|
+
| "$eq"
|
|
31
|
+
| "$ne"
|
|
32
|
+
| "$gt"
|
|
33
|
+
| "$gte"
|
|
34
|
+
| "$lt"
|
|
35
|
+
| "$lte"
|
|
36
|
+
| "$contains"
|
|
37
|
+
| "$startsWith"
|
|
38
|
+
| "$in";
|
|
39
|
+
|
|
40
|
+
export type ReadOptions = {
|
|
41
|
+
search?: string;
|
|
42
|
+
page?: number;
|
|
43
|
+
limit?: number;
|
|
44
|
+
sort?: string;
|
|
45
|
+
order?: "asc" | "desc";
|
|
46
|
+
populate?: string[] | "none";
|
|
47
|
+
filters?: Record<string, FilterValue>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
// Whitelist of operators — unknown operators are silently dropped, never interpolated.
|
|
52
|
+
const OPERATORS: Record<FilterOperator, (field: string, param: string) => string> = {
|
|
53
|
+
$eq: (f, p) => `${f} = $${p}`,
|
|
54
|
+
$ne: (f, p) => `${f} != $${p}`,
|
|
55
|
+
$gt: (f, p) => `${f} > $${p}`,
|
|
56
|
+
$gte: (f, p) => `${f} >= $${p}`,
|
|
57
|
+
$lt: (f, p) => `${f} < $${p}`,
|
|
58
|
+
$lte: (f, p) => `${f} <= $${p}`,
|
|
59
|
+
$contains: (f, p) => `string::contains(string::lowercase(${f}), $${p})`,
|
|
60
|
+
$startsWith: (f, p) => `string::starts_with(string::lowercase(${f}), $${p})`,
|
|
61
|
+
$in: (f, p) => `${f} CONTAINS $${p}`,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const makeModel = <Rules extends z.ZodObject>(
|
|
65
|
+
prefix: string,
|
|
66
|
+
config: {
|
|
67
|
+
schema: Rules;
|
|
68
|
+
name: string;
|
|
69
|
+
searchFields: string[];
|
|
70
|
+
},
|
|
71
|
+
) => {
|
|
72
|
+
type Data = z.infer<Rules>;
|
|
73
|
+
type Returning = Data & {
|
|
74
|
+
id: RecordId;
|
|
75
|
+
createdAt: string;
|
|
76
|
+
updatedAt: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const table = new Table(prefix);
|
|
80
|
+
|
|
81
|
+
const relationFields = Object.entries(config.schema.shape)
|
|
82
|
+
.filter(([, v]) => {
|
|
83
|
+
const m = (v as z.ZodType).meta() as any;
|
|
84
|
+
return ["relation", "multi_relation", "file", "multi_file"].includes(m?.renderAs);
|
|
85
|
+
})
|
|
86
|
+
.map(([k]) => k);
|
|
87
|
+
|
|
88
|
+
const relationFieldSet = new Set(relationFields);
|
|
89
|
+
|
|
90
|
+
const uniqueFields = Object.entries(config.schema.shape)
|
|
91
|
+
.filter(([, v]) => (v as z.ZodType).meta()?.unique === true)
|
|
92
|
+
.map(([k]) => k);
|
|
93
|
+
|
|
94
|
+
const uniqueFieldSet = new Set(uniqueFields);
|
|
95
|
+
|
|
96
|
+
// field must be a known unique field — prevents arbitrary field injection
|
|
97
|
+
const isDuplicate = async (field: string, value: unknown, excludeId?: string) => {
|
|
98
|
+
if (!uniqueFieldSet.has(field)) return false;
|
|
99
|
+
|
|
100
|
+
const excludeClause = excludeId ? "AND id != $excludeId" : "";
|
|
101
|
+
const [[row]] = await surreal().query<[{ count: number }[]]>(
|
|
102
|
+
`SELECT count() AS count FROM ${prefix} WHERE ${field} = $value ${excludeClause} GROUP ALL`,
|
|
103
|
+
{ value, excludeId: excludeId ? new RecordId(table, excludeId) : undefined },
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
return (row?.count ?? 0) > 0;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const assertUnique = async (data: Record<string, unknown>, excludeId?: string) => {
|
|
110
|
+
for (const field of uniqueFields) {
|
|
111
|
+
const value = data[field];
|
|
112
|
+
if (value === undefined) continue; // partial update without this field — nothing to check
|
|
113
|
+
|
|
114
|
+
if (await isDuplicate(field, value, excludeId)) {
|
|
115
|
+
throw new UniqueConstraintError(field, value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const create = async (data: Values<Data>) => {
|
|
121
|
+
await assertUnique(data);
|
|
122
|
+
|
|
123
|
+
return surreal()
|
|
124
|
+
.create<Data>(table)
|
|
125
|
+
.content({ ...data, createdAt: moment().toISOString(), updatedAt: moment().toISOString() })
|
|
126
|
+
.output("after")
|
|
127
|
+
.then(([data]) => data) as Promise<Returning>;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const read = async ({
|
|
131
|
+
search,
|
|
132
|
+
page = 1,
|
|
133
|
+
limit: pageLimit = 20,
|
|
134
|
+
sort,
|
|
135
|
+
order = "asc",
|
|
136
|
+
populate,
|
|
137
|
+
filters,
|
|
138
|
+
}: ReadOptions = {}) => {
|
|
139
|
+
const whereParts: string[] = [];
|
|
140
|
+
const params: Record<string, unknown> = {};
|
|
141
|
+
|
|
142
|
+
if (search && config.searchFields.length) {
|
|
143
|
+
params.search = search.toLowerCase();
|
|
144
|
+
whereParts.push(
|
|
145
|
+
`(${config.searchFields.map((f) => `string::similarity::jaro_winkler(string::lowercase(${f}), $search) > 0.4`).join(" OR ")})`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (filters) {
|
|
150
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
151
|
+
// key must exist in schema — prevents arbitrary field injection
|
|
152
|
+
if (!(key in config.schema.shape)) continue;
|
|
153
|
+
|
|
154
|
+
if (typeof value === "string") {
|
|
155
|
+
// Shorthand: filters[field]=value → equality
|
|
156
|
+
params[`f_${key}`] = value;
|
|
157
|
+
whereParts.push(`${key} = $f_${key}`);
|
|
158
|
+
} else {
|
|
159
|
+
// Operator form: filters[field][$op]=value
|
|
160
|
+
for (const [op, opValue] of Object.entries(value) as [FilterOperator, string][]) {
|
|
161
|
+
const builder = OPERATORS[op]; // strict whitelist — unknown ops get undefined
|
|
162
|
+
if (!builder || opValue === undefined) continue;
|
|
163
|
+
|
|
164
|
+
const pk = `f_${key}_${op.slice(1)}`; // e.g. f_title_contains
|
|
165
|
+
params[pk] =
|
|
166
|
+
op === "$contains" || op === "$startsWith"
|
|
167
|
+
? opValue.toLowerCase() // match the lowercased field
|
|
168
|
+
: opValue;
|
|
169
|
+
whereParts.push(builder(key, pk));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const where = whereParts.length ? `WHERE ${whereParts.join(" AND ")}` : "";
|
|
176
|
+
// id/createdAt/updatedAt are always present on every record but aren't part of the
|
|
177
|
+
// user-defined schema shape (they're injected in create()/update()) — allow sorting by them too.
|
|
178
|
+
const sortableFields = new Set([...Object.keys(config.schema.shape), "id", "createdAt", "updatedAt"]);
|
|
179
|
+
const orderBy = sort && sortableFields.has(sort) ? `ORDER BY ${sort} ${order === "desc" ? "DESC" : "ASC"}` : "";
|
|
180
|
+
|
|
181
|
+
// Validate populate against known relation fields — prevents FETCH injection
|
|
182
|
+
const fetchFields =
|
|
183
|
+
populate === "none"
|
|
184
|
+
? []
|
|
185
|
+
: populate
|
|
186
|
+
? populate.filter((f) => relationFieldSet.has(f))
|
|
187
|
+
: relationFields;
|
|
188
|
+
const fetch = fetchFields.length ? `FETCH ${fetchFields.join(", ")}` : "";
|
|
189
|
+
const start = (page - 1) * pageLimit;
|
|
190
|
+
|
|
191
|
+
const [data, [countRow]] = await surreal().query<[Returning[], { count: number }[]]>(
|
|
192
|
+
`SELECT * FROM ${prefix} ${where} ${orderBy} LIMIT ${pageLimit} START ${start} ${fetch};
|
|
193
|
+
SELECT count() as count FROM ${prefix} ${where} GROUP ALL`,
|
|
194
|
+
params,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const total = countRow?.count ?? 0;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
data: data ?? [],
|
|
201
|
+
meta: {
|
|
202
|
+
total,
|
|
203
|
+
page,
|
|
204
|
+
limit: pageLimit,
|
|
205
|
+
pageCount: Math.ceil(total / pageLimit),
|
|
206
|
+
} satisfies ReadMeta,
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const show = (id: string) => {
|
|
211
|
+
let q = surreal().select<Data>(new RecordId(table, id));
|
|
212
|
+
if (relationFields.length) q = q.fetch(...(relationFields as [string, ...string[]])) as typeof q;
|
|
213
|
+
return q as unknown as Promise<Returning>;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const update = async (id: string, data: Values<Partial<Data>>) => {
|
|
217
|
+
await assertUnique(data, id);
|
|
218
|
+
|
|
219
|
+
return surreal()
|
|
220
|
+
.update<Data>(new RecordId(table, id))
|
|
221
|
+
.merge({
|
|
222
|
+
...data,
|
|
223
|
+
updatedAt: moment().toISOString(),
|
|
224
|
+
}) as unknown as Promise<Returning>;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const destroy = (id: string) => {
|
|
228
|
+
return surreal().delete<Data>(new RecordId(table, id)) as unknown as Promise<Returning>;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
name: config.name,
|
|
233
|
+
prefix,
|
|
234
|
+
create,
|
|
235
|
+
destroy,
|
|
236
|
+
read,
|
|
237
|
+
show,
|
|
238
|
+
table,
|
|
239
|
+
update,
|
|
240
|
+
validation: config.schema,
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export * from "./data_type";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ModuleReturn = {
|
|
2
|
+
spin?: () => Promise<void> | void;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export interface Module<Return> {
|
|
6
|
+
(): Return;
|
|
7
|
+
init: () => Promise<ModuleReturn> | ModuleReturn;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const makeModule = <Return>(creator: () => Module<Return>) => {
|
|
11
|
+
return creator();
|
|
12
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { join, normalize } from "node:path";
|
|
2
|
+
|
|
3
|
+
// Resolves `requestedPath` against `baseDir` and rejects anything that escapes it
|
|
4
|
+
// (e.g. "../../etc/passwd") — shared by every module that serves files straight
|
|
5
|
+
// off disk based on a request path (uploads, admin panel SPA, etc).
|
|
6
|
+
export const resolveSafePath = (baseDir: string, requestedPath: string): string | null => {
|
|
7
|
+
const resolved = normalize(join(baseDir, requestedPath));
|
|
8
|
+
return resolved.startsWith(baseDir) ? resolved : null;
|
|
9
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getConfig } from "../core/config";
|
|
2
|
+
import { makeModel, numeric, text } from "../core/model";
|
|
3
|
+
import z from "zod";
|
|
4
|
+
|
|
5
|
+
const file = makeModel("file", {
|
|
6
|
+
name: "File",
|
|
7
|
+
schema: z.object({
|
|
8
|
+
meta: getConfig().adapters.file.meta,
|
|
9
|
+
name: text({ label: "Filename" }),
|
|
10
|
+
size: numeric({ label: "Size" }),
|
|
11
|
+
type: text({ label: "Mime Type" }),
|
|
12
|
+
url: text({ label: "URL" }),
|
|
13
|
+
}),
|
|
14
|
+
searchFields: ["name"],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export default file;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { email, makeModel, text } from "../core/model";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
|
|
4
|
+
const user = makeModel("user", {
|
|
5
|
+
name: "Pengguna",
|
|
6
|
+
schema: z.object({
|
|
7
|
+
name: text({ label: "Name" }),
|
|
8
|
+
email: email({ label: "Email", unique: true }),
|
|
9
|
+
password: text({ label: "Password" }, (z) =>
|
|
10
|
+
z.transform((value) => {
|
|
11
|
+
return Bun.password.hashSync(value);
|
|
12
|
+
}),
|
|
13
|
+
),
|
|
14
|
+
}),
|
|
15
|
+
searchFields: ["name", "email"],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default user;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { makeModule } from "../core/module";
|
|
2
|
+
import { resolveSafePath } from "../core/path_guard";
|
|
3
|
+
import Elysia from "elysia";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import elysia from "./elysia";
|
|
6
|
+
|
|
7
|
+
const adminDir = join(process.cwd(), "_admin");
|
|
8
|
+
const indexFile = () => Bun.file(join(adminDir, "index.html"));
|
|
9
|
+
|
|
10
|
+
const admin = makeModule(() => {
|
|
11
|
+
const fn = () => {};
|
|
12
|
+
|
|
13
|
+
fn.init = () => {
|
|
14
|
+
const controller = new Elysia()
|
|
15
|
+
.get("/_admin", () => indexFile())
|
|
16
|
+
.get("/_admin/*", async ({ params }) => {
|
|
17
|
+
// Elysia leaves wildcard params percent-encoded — decode before touching the filesystem.
|
|
18
|
+
let decoded: string;
|
|
19
|
+
try {
|
|
20
|
+
decoded = decodeURIComponent(params["*"]);
|
|
21
|
+
} catch {
|
|
22
|
+
return indexFile();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const requested = resolveSafePath(adminDir, decoded);
|
|
26
|
+
if (!requested) return indexFile();
|
|
27
|
+
|
|
28
|
+
const file = Bun.file(requested);
|
|
29
|
+
if (await file.exists()) return file;
|
|
30
|
+
|
|
31
|
+
// SPA fallback — let the admin panel's client-side router handle unknown paths.
|
|
32
|
+
return indexFile();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
elysia().use(controller);
|
|
36
|
+
|
|
37
|
+
return {};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return fn;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export default admin;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { makeModule } from "../core/module";
|
|
2
|
+
import Elysia from "elysia";
|
|
3
|
+
import geoip from "geoip-lite";
|
|
4
|
+
import moment from "moment";
|
|
5
|
+
import { Table } from "surrealdb";
|
|
6
|
+
import z from "zod";
|
|
7
|
+
import auth from "./auth";
|
|
8
|
+
import elysia from "./elysia";
|
|
9
|
+
import logger from "./logger";
|
|
10
|
+
import surreal from "./surreal";
|
|
11
|
+
|
|
12
|
+
const table = new Table("pageview");
|
|
13
|
+
|
|
14
|
+
type TrackInput = {
|
|
15
|
+
path: string;
|
|
16
|
+
referrer: string | null;
|
|
17
|
+
userAgent: string | null;
|
|
18
|
+
ip: string | null;
|
|
19
|
+
requestUrl: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Same-origin referrer just means "navigated from another page on this site" —
|
|
23
|
+
// not an actual external traffic source, so it shouldn't be counted as one.
|
|
24
|
+
export const isSameOrigin = (referrer: string | null, requestUrl: string) => {
|
|
25
|
+
if (!referrer) return false;
|
|
26
|
+
try {
|
|
27
|
+
return new URL(referrer).origin === new URL(requestUrl).origin;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// `server.requestIP()` only sees the direct TCP peer — behind a reverse proxy
|
|
34
|
+
// (nginx, etc.) that's the proxy itself, not the real visitor. Prefer the
|
|
35
|
+
// headers a trusted proxy sets; fall back to the socket address for direct connections.
|
|
36
|
+
export const resolveClientIp = (
|
|
37
|
+
request: Request,
|
|
38
|
+
server: { requestIP: (req: Request) => { address: string } | null } | null,
|
|
39
|
+
) => {
|
|
40
|
+
const forwardedFor = request.headers.get("x-forwarded-for");
|
|
41
|
+
if (forwardedFor) return forwardedFor.split(",")[0]!.trim();
|
|
42
|
+
|
|
43
|
+
const realIp = request.headers.get("x-real-ip");
|
|
44
|
+
if (realIp) return realIp.trim();
|
|
45
|
+
|
|
46
|
+
return server?.requestIP(request)?.address ?? null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const hashVisitor = (ip: string, userAgent: string) => {
|
|
50
|
+
const today = moment().format("YYYY-MM-DD");
|
|
51
|
+
return new Bun.CryptoHasher("sha256").update(`${ip}:${userAgent}:${today}`).digest("hex");
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const lookupCountry = (ip: string | null) => {
|
|
55
|
+
if (!ip) return null;
|
|
56
|
+
return geoip.lookup(ip)?.country ?? null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// from/to must be full ISO datetime strings — createdAt comparison is plain string
|
|
60
|
+
// comparison, and a date-only value ("YYYY-MM-DD") would silently exclude that whole
|
|
61
|
+
// day's records since it sorts before any datetime sharing that prefix.
|
|
62
|
+
const rangeQuery = z.object({
|
|
63
|
+
from: z.iso.datetime({ offset: true }).optional(),
|
|
64
|
+
to: z.iso.datetime({ offset: true }).optional(),
|
|
65
|
+
path: z.coerce.string().optional(),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const buildWhere = (from?: string, to?: string, path?: string) => {
|
|
69
|
+
const parts: string[] = [];
|
|
70
|
+
const params: Record<string, unknown> = {};
|
|
71
|
+
|
|
72
|
+
if (from) {
|
|
73
|
+
parts.push("createdAt >= $from");
|
|
74
|
+
params.from = from;
|
|
75
|
+
}
|
|
76
|
+
if (to) {
|
|
77
|
+
parts.push("createdAt <= $to");
|
|
78
|
+
params.to = to;
|
|
79
|
+
}
|
|
80
|
+
if (path) {
|
|
81
|
+
parts.push("path = $path");
|
|
82
|
+
params.path = path;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { where: parts.length ? `WHERE ${parts.join(" AND ")}` : "", params };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const analytics = makeModule(() => {
|
|
89
|
+
const fn = () => ({
|
|
90
|
+
track: async ({ path, referrer, userAgent, ip, requestUrl }: TrackInput) => {
|
|
91
|
+
try {
|
|
92
|
+
const country = lookupCountry(ip);
|
|
93
|
+
const visitorHash = ip && userAgent ? hashVisitor(ip, userAgent) : null;
|
|
94
|
+
const externalReferrer = isSameOrigin(referrer, requestUrl) ? null : referrer;
|
|
95
|
+
|
|
96
|
+
await surreal()
|
|
97
|
+
.create(table)
|
|
98
|
+
.content({
|
|
99
|
+
path,
|
|
100
|
+
referrer: externalReferrer,
|
|
101
|
+
userAgent,
|
|
102
|
+
country,
|
|
103
|
+
visitorHash,
|
|
104
|
+
createdAt: moment().toISOString(),
|
|
105
|
+
});
|
|
106
|
+
} catch (e) {
|
|
107
|
+
logger().error(e, "[analytics] failed to track pageview");
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
fn.init = async () => {
|
|
113
|
+
const authPlugin = (await auth()).plugin;
|
|
114
|
+
|
|
115
|
+
const controller = new Elysia({ prefix: "/api/analytics" })
|
|
116
|
+
.use(authPlugin)
|
|
117
|
+
.get(
|
|
118
|
+
"/summary",
|
|
119
|
+
async ({ query: { from, to, path } }) => {
|
|
120
|
+
const { where, params } = buildWhere(from, to, path);
|
|
121
|
+
const [rows] = await surreal().query<[{ visitorHash: string | null }[]]>(
|
|
122
|
+
`SELECT visitorHash FROM pageview ${where}`,
|
|
123
|
+
params,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const totalPageviews = rows?.length ?? 0;
|
|
127
|
+
const uniqueVisitors = new Set((rows ?? []).map((r) => r.visitorHash).filter(Boolean)).size;
|
|
128
|
+
|
|
129
|
+
return { data: { totalPageviews, uniqueVisitors } };
|
|
130
|
+
},
|
|
131
|
+
{ auth: true, query: rangeQuery },
|
|
132
|
+
)
|
|
133
|
+
.get(
|
|
134
|
+
"/by-country",
|
|
135
|
+
async ({ query: { from, to, path } }) => {
|
|
136
|
+
const { where, params } = buildWhere(from, to, path);
|
|
137
|
+
const [rows] = await surreal().query<[{ country: string | null; count: number }[]]>(
|
|
138
|
+
`SELECT country, count() AS count FROM pageview ${where} GROUP BY country ORDER BY count DESC`,
|
|
139
|
+
params,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return { data: rows ?? [] };
|
|
143
|
+
},
|
|
144
|
+
{ auth: true, query: rangeQuery },
|
|
145
|
+
)
|
|
146
|
+
.get(
|
|
147
|
+
"/by-referrer",
|
|
148
|
+
async ({ query: { from, to, path } }) => {
|
|
149
|
+
const { where, params } = buildWhere(from, to, path);
|
|
150
|
+
const [rows] = await surreal().query<[{ referrer: string | null; count: number }[]]>(
|
|
151
|
+
`SELECT referrer, count() AS count FROM pageview ${where} GROUP BY referrer ORDER BY count DESC`,
|
|
152
|
+
params,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
return { data: rows ?? [] };
|
|
156
|
+
},
|
|
157
|
+
{ auth: true, query: rangeQuery },
|
|
158
|
+
)
|
|
159
|
+
.get(
|
|
160
|
+
"/by-page",
|
|
161
|
+
async ({ query: { from, to } }) => {
|
|
162
|
+
const { where, params } = buildWhere(from, to, undefined);
|
|
163
|
+
const [rows] = await surreal().query<[{ path: string; count: number }[]]>(
|
|
164
|
+
`SELECT path, count() AS count FROM pageview ${where} GROUP BY path ORDER BY count DESC`,
|
|
165
|
+
params,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
return { data: rows ?? [] };
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
auth: true,
|
|
172
|
+
query: rangeQuery.omit({ path: true }),
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
.get(
|
|
176
|
+
"/timeseries",
|
|
177
|
+
async ({ query: { from, to, path, interval } }) => {
|
|
178
|
+
const { where, params } = buildWhere(from, to, path);
|
|
179
|
+
const bucketLen = interval === "hour" ? 13 : 10; // "YYYY-MM-DDTHH" vs "YYYY-MM-DD"
|
|
180
|
+
|
|
181
|
+
const [rows] = await surreal().query<[{ date: string; count: number }[]]>(
|
|
182
|
+
`SELECT string::slice(createdAt, 0, ${bucketLen}) AS date, count() AS count
|
|
183
|
+
FROM pageview ${where}
|
|
184
|
+
GROUP BY date
|
|
185
|
+
ORDER BY date ASC`,
|
|
186
|
+
params,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return { data: rows ?? [] };
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
auth: true,
|
|
193
|
+
query: rangeQuery.extend({
|
|
194
|
+
interval: z.enum(["day", "hour"]).default("day"),
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
elysia().use(controller);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
// Runs after surreal's own spin() has connected — init() itself runs too early for DB queries.
|
|
203
|
+
spin: async () => {
|
|
204
|
+
await surreal().query("DEFINE TABLE IF NOT EXISTS pageview SCHEMALESS;");
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
return fn;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
export default analytics;
|