@njinlabs/njin 0.1.2 → 0.2.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/README.md +148 -1
- package/package.json +3 -2
- package/src/cli/build.ts +84 -48
- package/src/config/module.ts +11 -1
- package/src/core/adapters/bun_filesystem.ts +2 -1
- package/src/core/adapters/s3.ts +2 -1
- package/src/core/config.ts +54 -2
- package/src/core/event.ts +45 -0
- package/src/core/model/hooks.ts +143 -0
- package/src/core/model/index.ts +44 -11
- package/src/core/path_guard.ts +16 -2
- package/src/core/plugin.ts +13 -0
- package/src/core/route.ts +6 -0
- package/src/core/vars/index.ts +64 -0
- package/src/modules/api.ts +57 -20
- package/src/modules/img.ts +141 -137
- package/src/modules/plugin.ts +17 -0
- package/src/modules/surreal.ts +38 -13
- package/src/modules/vars.ts +34 -0
- package/src/modules/view.ts +9 -4
package/src/modules/img.ts
CHANGED
|
@@ -1,137 +1,141 @@
|
|
|
1
|
-
import { getConfig } from "../core/config";
|
|
2
|
-
import { makeModule } from "../core/module";
|
|
3
|
-
import Elysia from "elysia";
|
|
4
|
-
import elysia from "./elysia";
|
|
5
|
-
import view from "./view";
|
|
6
|
-
|
|
7
|
-
export function isAllowed(imageUrl: string,
|
|
8
|
-
if (imageUrl.startsWith("/")) return true;
|
|
9
|
-
|
|
10
|
-
let parsed: URL;
|
|
11
|
-
try {
|
|
12
|
-
parsed = new URL(imageUrl);
|
|
13
|
-
} catch {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const imageHostname = parsed.hostname;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (options?.
|
|
49
|
-
if (options?.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
1
|
+
import { getConfig } from "../core/config";
|
|
2
|
+
import { makeModule } from "../core/module";
|
|
3
|
+
import Elysia from "elysia";
|
|
4
|
+
import elysia from "./elysia";
|
|
5
|
+
import view from "./view";
|
|
6
|
+
|
|
7
|
+
export function isAllowed(imageUrl: string, allowedHosts: string[] = getConfig().img.hosts): boolean {
|
|
8
|
+
if (imageUrl.startsWith("/")) return true;
|
|
9
|
+
|
|
10
|
+
let parsed: URL;
|
|
11
|
+
try {
|
|
12
|
+
parsed = new URL(imageUrl);
|
|
13
|
+
} catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const imageHostname = parsed.hostname;
|
|
18
|
+
|
|
19
|
+
// localhost variants are only trusted outside production — covers the Vite dev
|
|
20
|
+
// server running on a different port (5173). Trusting the client-supplied `Host`
|
|
21
|
+
// header, or allowing loopback unconditionally, would turn this into an SSRF
|
|
22
|
+
// primitive (an attacker controls both the `url` query param and their own
|
|
23
|
+
// request's `Host` header), so neither is honored here.
|
|
24
|
+
if (process.env.NODE_ENV !== "production" && (imageHostname === "localhost" || imageHostname === "127.0.0.1")) return true;
|
|
25
|
+
|
|
26
|
+
// Every other host: require explicit whitelist
|
|
27
|
+
return allowedHosts.includes(imageHostname);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function extractFilename(url: string): string {
|
|
31
|
+
try {
|
|
32
|
+
const pathname = url.startsWith("/") ? url : new URL(url).pathname;
|
|
33
|
+
const last = pathname.split("/").filter(Boolean).pop() ?? "image";
|
|
34
|
+
const name = last.replace(/\.[^.]+$/, "") || "image";
|
|
35
|
+
return `${name}.webp`;
|
|
36
|
+
} catch {
|
|
37
|
+
return "image.webp";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const img = makeModule(() => {
|
|
42
|
+
const fn = () => {};
|
|
43
|
+
|
|
44
|
+
fn.init = async () => {
|
|
45
|
+
view().global("imgOptimize", (url: string, options?: { w?: number; h?: number; q?: number }) => {
|
|
46
|
+
const params = new URLSearchParams({ url });
|
|
47
|
+
if (options?.w != null) params.set("w", String(options.w));
|
|
48
|
+
if (options?.h != null) params.set("h", String(options.h));
|
|
49
|
+
if (options?.q != null) params.set("q", String(options.q));
|
|
50
|
+
return `/img?${params.toString()}`;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const controller = new Elysia();
|
|
54
|
+
|
|
55
|
+
controller.get("/img", async ({ query, request }) => {
|
|
56
|
+
const { url, w, h, q = "80" } = query;
|
|
57
|
+
|
|
58
|
+
if (!url) {
|
|
59
|
+
return new Response("Missing url parameter", { status: 400 });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const width = w ? parseInt(w) : undefined;
|
|
63
|
+
const height = h ? parseInt(h) : undefined;
|
|
64
|
+
const quality = Math.min(100, Math.max(1, parseInt(q)));
|
|
65
|
+
|
|
66
|
+
if (isNaN(quality)) {
|
|
67
|
+
return new Response("Invalid q parameter", { status: 400 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!isAllowed(url)) {
|
|
71
|
+
return new Response("Host not allowed", { status: 403 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ETag is deterministic from input params — allows 304 without fetching or processing
|
|
75
|
+
const etag = `"${Bun.hash(`${url}|${w ?? ""}|${h ?? ""}|${quality}`).toString(16)}"`;
|
|
76
|
+
const cacheControl = "public, max-age=31536000, immutable";
|
|
77
|
+
|
|
78
|
+
if (request.headers.get("if-none-match") === etag) {
|
|
79
|
+
return new Response(null, {
|
|
80
|
+
status: 304,
|
|
81
|
+
headers: { ETag: etag, "Cache-Control": cacheControl },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let imageData: ArrayBuffer;
|
|
86
|
+
|
|
87
|
+
if (url.startsWith("/")) {
|
|
88
|
+
const resp = await fetch(`http://localhost:${getConfig().port}${url}`);
|
|
89
|
+
if (!resp.ok) return new Response("Image not found", { status: 404 });
|
|
90
|
+
imageData = await resp.arrayBuffer();
|
|
91
|
+
} else {
|
|
92
|
+
// `redirect: "error"` — a validated host redirecting to an unvalidated
|
|
93
|
+
// (e.g. internal) one would otherwise silently defeat the isAllowed() check above.
|
|
94
|
+
let resp: Response;
|
|
95
|
+
try {
|
|
96
|
+
resp = await fetch(url, { redirect: "error" });
|
|
97
|
+
} catch {
|
|
98
|
+
return new Response("Failed to fetch image", { status: 502 });
|
|
99
|
+
}
|
|
100
|
+
if (!resp.ok) return new Response("Failed to fetch image", { status: 502 });
|
|
101
|
+
imageData = await resp.arrayBuffer();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pipeline = new Bun.Image(imageData);
|
|
105
|
+
|
|
106
|
+
if (width) {
|
|
107
|
+
// Bun.Image.resize() takes width first and derives height from aspect ratio
|
|
108
|
+
// when height is omitted — there's no height-only equivalent, so width-only
|
|
109
|
+
// and width+height both go through this branch unchanged.
|
|
110
|
+
pipeline.resize(width, height, { fit: "inside", withoutEnlargement: true });
|
|
111
|
+
} else if (height) {
|
|
112
|
+
// Height-only request — derive a proportional width from the source dimensions
|
|
113
|
+
// since resize() requires a width argument.
|
|
114
|
+
const meta = await pipeline.metadata();
|
|
115
|
+
const derivedWidth = Math.round((meta.width / meta.height) * height);
|
|
116
|
+
pipeline.resize(derivedWidth, height, { fit: "inside", withoutEnlargement: true });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const output = await pipeline.webp({ quality }).buffer();
|
|
120
|
+
const filename = extractFilename(url);
|
|
121
|
+
|
|
122
|
+
return new Response(new Uint8Array(output), {
|
|
123
|
+
headers: {
|
|
124
|
+
"Content-Type": "image/webp",
|
|
125
|
+
"Content-Length": String(output.byteLength),
|
|
126
|
+
"Content-Disposition": `inline; filename="${filename}"`,
|
|
127
|
+
"Cache-Control": cacheControl,
|
|
128
|
+
ETag: etag,
|
|
129
|
+
Vary: "Accept",
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
elysia().use(controller);
|
|
135
|
+
return {};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return fn;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export default img;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getConfig } from "../core/config";
|
|
2
|
+
import { makeModule } from "../core/module";
|
|
3
|
+
|
|
4
|
+
const plugin = makeModule(() => {
|
|
5
|
+
const fn = () => {};
|
|
6
|
+
|
|
7
|
+
fn.init = async () => {
|
|
8
|
+
// Sequential, awaited, and never caught — a plugin whose init() throws must fail
|
|
9
|
+
// boot loudly, same contract as hook/event side-effect imports in api.ts.
|
|
10
|
+
for (const init of getConfig().pluginInits) await init();
|
|
11
|
+
return {};
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
return fn;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export default plugin;
|
package/src/modules/surreal.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { getConfig } from "../core/config";
|
|
2
2
|
import { makeModule } from "../core/module";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { Surreal, createRemoteEngines } from "surrealdb";
|
|
4
|
+
import type { Engines } from "surrealdb";
|
|
5
|
+
|
|
6
|
+
const REMOTE_SCHEMES = ["ws://", "wss://", "http://", "https://"];
|
|
7
|
+
const EMBEDDED_SCHEMES = ["mem://", "rocksdb://", "surrealkv://"];
|
|
8
|
+
|
|
9
|
+
export const isRemotePath = (path: string) => REMOTE_SCHEMES.some((scheme) => path.startsWith(scheme));
|
|
5
10
|
|
|
6
11
|
// SurrealDB's `GROUP ALL` aggregate (used for count queries) throws NotFoundError
|
|
7
12
|
// on a table that has never had a row created, unlike plain SELECT. Defining every
|
|
@@ -10,7 +15,7 @@ const ensureTables = async (db: Surreal) => {
|
|
|
10
15
|
const { default: userModel } = await import("../models/user");
|
|
11
16
|
const { default: fileModel } = await import("../models/file");
|
|
12
17
|
|
|
13
|
-
const prefixes = new Set<string>([userModel.prefix, fileModel.prefix]);
|
|
18
|
+
const prefixes = new Set<string>([userModel.prefix, fileModel.prefix, "vars"]);
|
|
14
19
|
|
|
15
20
|
for (const factory of getConfig().models) {
|
|
16
21
|
const { default: model } = await factory();
|
|
@@ -29,18 +34,38 @@ const surreal = makeModule(() => {
|
|
|
29
34
|
return db;
|
|
30
35
|
};
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
// Connect during init(), not spin() — init() calls for every module run (in
|
|
38
|
+
// src/config/module.ts's array construction) before any module's spin() runs, and a
|
|
39
|
+
// plugin's own init() (src/modules/plugin.ts) may query the DB (e.g. reading its own
|
|
40
|
+
// vars group) as part of its own setup. Deferring the actual connect to spin() left a
|
|
41
|
+
// window where `surreal()` returned an instance that existed but had never connected,
|
|
42
|
+
// so any DB call made from a plugin's init() failed with ConnectionUnavailableError.
|
|
43
|
+
fn.init = async () => {
|
|
44
|
+
const { db: dbConfig } = getConfig();
|
|
36
45
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
let engines: Engines;
|
|
47
|
+
if (isRemotePath(dbConfig.path)) {
|
|
48
|
+
engines = createRemoteEngines();
|
|
49
|
+
} else if (EMBEDDED_SCHEMES.some((scheme) => dbConfig.path.startsWith(scheme))) {
|
|
50
|
+
// Dynamically imported (not a static top-level import) so a compiled build whose
|
|
51
|
+
// config resolves to a remote db.path can exclude @surrealdb/node entirely — see
|
|
52
|
+
// src/cli/build.ts, which externalizes this package for remote-only builds since its
|
|
53
|
+
// native binding loader runs eagerly on import, not lazily on first use.
|
|
54
|
+
const { createNodeEngines } = await import("@surrealdb/node");
|
|
55
|
+
engines = createNodeEngines();
|
|
56
|
+
} else {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Unrecognized db.path scheme: "${dbConfig.path}" — expected one of ${EMBEDDED_SCHEMES.join(", ")} (embedded) or ${REMOTE_SCHEMES.join(", ")} (remote)`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
43
61
|
|
|
62
|
+
db = new Surreal({ engines });
|
|
63
|
+
await db.connect(dbConfig.path, { authentication: dbConfig.auth });
|
|
64
|
+
await db.use({ namespace: dbConfig.namespace, database: dbConfig.database });
|
|
65
|
+
await ensureTables(db);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
spin: () => {
|
|
44
69
|
const shutdown = async () => {
|
|
45
70
|
await db.close();
|
|
46
71
|
process.exit(0);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getConfig } from "../core/config";
|
|
2
|
+
import { makeModule } from "../core/module";
|
|
3
|
+
import Elysia from "elysia";
|
|
4
|
+
import auth from "./auth";
|
|
5
|
+
import elysia from "./elysia";
|
|
6
|
+
|
|
7
|
+
const vars = makeModule(() => {
|
|
8
|
+
const fn = () => {};
|
|
9
|
+
|
|
10
|
+
fn.init = async () => {
|
|
11
|
+
const groups = getConfig().vars;
|
|
12
|
+
const authPlugin = await auth();
|
|
13
|
+
|
|
14
|
+
for (const promise of groups) {
|
|
15
|
+
const { default: group } = await promise();
|
|
16
|
+
|
|
17
|
+
const controller = new Elysia({ prefix: `/api/vars/${group.prefix}` })
|
|
18
|
+
.use(authPlugin.plugin)
|
|
19
|
+
.get("/", async () => ({ data: await group.get() }), { auth: true })
|
|
20
|
+
.put("/", async ({ body }) => ({ data: await group.update(body) }), {
|
|
21
|
+
auth: true,
|
|
22
|
+
body: group.validation.partial(),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
elysia().use(controller);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return fn;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default vars;
|
package/src/modules/view.ts
CHANGED
|
@@ -20,7 +20,7 @@ type ViteGlobal = {
|
|
|
20
20
|
static: (path: string) => string;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
const buildViteGlobal = async (): Promise<ViteGlobal> => {
|
|
23
|
+
export const buildViteGlobal = async (): Promise<ViteGlobal> => {
|
|
24
24
|
if (isDev) {
|
|
25
25
|
const { createServer } = await import("vite");
|
|
26
26
|
const server = await createServer();
|
|
@@ -97,6 +97,11 @@ const view = makeModule(() => {
|
|
|
97
97
|
edge.global(model.prefix, model);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
for (const varsPromise of getConfig().vars) {
|
|
101
|
+
const { default: group } = await varsPromise();
|
|
102
|
+
edge.global(group.prefix, group);
|
|
103
|
+
}
|
|
104
|
+
|
|
100
105
|
const controller = new Elysia();
|
|
101
106
|
|
|
102
107
|
if (!isDev) {
|
|
@@ -188,7 +193,7 @@ const view = makeModule(() => {
|
|
|
188
193
|
return fn;
|
|
189
194
|
});
|
|
190
195
|
|
|
191
|
-
async function renderHttpError(edge: Edge, viewsDir: string, error: HttpError): Promise<string> {
|
|
196
|
+
export async function renderHttpError(edge: Edge, viewsDir: string, error: HttpError): Promise<string> {
|
|
192
197
|
const templateFile = Bun.file(join(viewsDir, `errors/${error.statusCode}.edge`));
|
|
193
198
|
if (await templateFile.exists()) {
|
|
194
199
|
try {
|
|
@@ -207,7 +212,7 @@ async function renderHttpError(edge: Edge, viewsDir: string, error: HttpError):
|
|
|
207
212
|
</head><body><div class="c"><div class="n">${code}</div><p class="m">${error.message}</p><a href="/">← Back to home</a></div></body></html>`;
|
|
208
213
|
}
|
|
209
214
|
|
|
210
|
-
function renderErrorPage(error: Error, template: string, path: string): string {
|
|
215
|
+
export function renderErrorPage(error: Error, template: string, path: string): string {
|
|
211
216
|
const stack = (error.stack ?? "").replace(/</g, "<").replace(/>/g, ">");
|
|
212
217
|
const message = error.message.replace(/</g, "<").replace(/>/g, ">");
|
|
213
218
|
|
|
@@ -237,7 +242,7 @@ function renderErrorPage(error: Error, template: string, path: string): string {
|
|
|
237
242
|
</html>`;
|
|
238
243
|
}
|
|
239
244
|
|
|
240
|
-
function fileToRoute(file: string): string {
|
|
245
|
+
export function fileToRoute(file: string): string {
|
|
241
246
|
let route = file.replace(/\\/g, "/").replace(/\.edge$/, "");
|
|
242
247
|
route = route.replace(/\[([^\]]+)\]/g, ":$1");
|
|
243
248
|
route = route.replace(/\/index$|^index$/, "");
|