@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/modules/api.ts
CHANGED
|
@@ -1,61 +1,11 @@
|
|
|
1
|
-
import { getConfig } from "../core/config";
|
|
2
|
-
import { makeModule } from "../core/module";
|
|
3
1
|
import Elysia from "elysia";
|
|
4
2
|
import z from "zod";
|
|
3
|
+
import { toAdminSchema } from "../core/admin_schema";
|
|
4
|
+
import { getConfig } from "../core/config";
|
|
5
|
+
import { makeModule } from "../core/module";
|
|
5
6
|
import auth from "./auth";
|
|
6
7
|
import elysia from "./elysia";
|
|
7
8
|
|
|
8
|
-
// Removes fields marked hideForm: true from a JSON schema's properties/required,
|
|
9
|
-
// recursing into nested object/array shapes so hidden fields never reach the admin panel.
|
|
10
|
-
const stripHiddenFields = (node: any): void => {
|
|
11
|
-
if (!node || typeof node !== "object") return;
|
|
12
|
-
|
|
13
|
-
if (node.properties) {
|
|
14
|
-
for (const [key, prop] of Object.entries(node.properties as Record<string, any>)) {
|
|
15
|
-
if (prop?.hideForm) {
|
|
16
|
-
delete node.properties[key];
|
|
17
|
-
if (Array.isArray(node.required)) {
|
|
18
|
-
node.required = node.required.filter((r: string) => r !== key);
|
|
19
|
-
}
|
|
20
|
-
} else {
|
|
21
|
-
stripHiddenFields(prop);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
if (node.items) stripHiddenFields(node.items);
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// Shared by both models and vars groups on the /api/schema endpoint — strips
|
|
30
|
-
// non-JSON-representable renderAs types (relation/multi_relation/file) into
|
|
31
|
-
// plain JSON-schema shapes the admin panel can render a form from, and drops
|
|
32
|
-
// any field marked hideForm: true so it never reaches the admin panel.
|
|
33
|
-
const toAdminSchema = (schema: z.ZodObject) => {
|
|
34
|
-
const jsonSchema = schema.toJSONSchema({
|
|
35
|
-
unrepresentable: "any",
|
|
36
|
-
override: (ctx) => {
|
|
37
|
-
if (ctx.jsonSchema.renderAs === "relation") {
|
|
38
|
-
ctx.jsonSchema.type = "object";
|
|
39
|
-
ctx.jsonSchema.properties = {
|
|
40
|
-
id: {
|
|
41
|
-
type: "string",
|
|
42
|
-
},
|
|
43
|
-
};
|
|
44
|
-
ctx.jsonSchema.required = ["id"];
|
|
45
|
-
ctx.jsonSchema.additionalProperties = {};
|
|
46
|
-
} else if (ctx.jsonSchema.renderAs === "multi_relation") {
|
|
47
|
-
(ctx.jsonSchema.items! as any).type = "string";
|
|
48
|
-
} else if (ctx.jsonSchema.renderAs === "file") {
|
|
49
|
-
ctx.jsonSchema.type = "string";
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
stripHiddenFields(jsonSchema);
|
|
55
|
-
|
|
56
|
-
return jsonSchema;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
9
|
const api = makeModule(() => {
|
|
60
10
|
const fn = () => {};
|
|
61
11
|
|
|
@@ -75,43 +25,45 @@ const api = makeModule(() => {
|
|
|
75
25
|
// every listener file's .listen() registration before any route goes live.
|
|
76
26
|
for (const promise of events) await promise();
|
|
77
27
|
|
|
78
|
-
const controller = new Elysia({ prefix: "/api/schema" })
|
|
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
|
-
|
|
28
|
+
const controller = new Elysia({ prefix: "/api/schema" })
|
|
29
|
+
.use(authPlugin.plugin)
|
|
30
|
+
.get(
|
|
31
|
+
"/",
|
|
32
|
+
async () => {
|
|
33
|
+
const allModels = await Promise.all(
|
|
34
|
+
models.map((model) =>
|
|
35
|
+
(async () => {
|
|
36
|
+
const { default: data } = await model();
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
name: data.name,
|
|
40
|
+
prefix: data.prefix,
|
|
41
|
+
schema: toAdminSchema(data.validation),
|
|
42
|
+
};
|
|
43
|
+
})(),
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const allVars = await Promise.all(
|
|
48
|
+
varsGroups.map((varsGroup) =>
|
|
49
|
+
(async () => {
|
|
50
|
+
const { default: group } = await varsGroup();
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
name: group.name,
|
|
54
|
+
prefix: group.prefix,
|
|
55
|
+
schema: toAdminSchema(group.validation),
|
|
56
|
+
};
|
|
57
|
+
})(),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return { data: allModels, vars: allVars };
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
auth: true,
|
|
65
|
+
},
|
|
66
|
+
);
|
|
115
67
|
|
|
116
68
|
elysia().use(controller);
|
|
117
69
|
|
|
@@ -185,11 +137,25 @@ const api = makeModule(() => {
|
|
|
185
137
|
)
|
|
186
138
|
.get(
|
|
187
139
|
"/",
|
|
188
|
-
async ({
|
|
140
|
+
async ({
|
|
141
|
+
query: { search, page, limit, sort, order, populate, filters },
|
|
142
|
+
}) => {
|
|
189
143
|
const populateOpt =
|
|
190
|
-
populate === "none"
|
|
191
|
-
|
|
192
|
-
|
|
144
|
+
populate === "none"
|
|
145
|
+
? ("none" as const)
|
|
146
|
+
: populate
|
|
147
|
+
? populate.split(",").map((s) => s.trim())
|
|
148
|
+
: undefined;
|
|
149
|
+
|
|
150
|
+
return model.read({
|
|
151
|
+
search,
|
|
152
|
+
page,
|
|
153
|
+
limit,
|
|
154
|
+
sort,
|
|
155
|
+
order,
|
|
156
|
+
populate: populateOpt,
|
|
157
|
+
filters,
|
|
158
|
+
});
|
|
193
159
|
},
|
|
194
160
|
{
|
|
195
161
|
auth: true,
|
|
@@ -201,7 +167,13 @@ const api = makeModule(() => {
|
|
|
201
167
|
order: z.enum(["asc", "desc"]).default("asc"),
|
|
202
168
|
populate: z.coerce.string().optional(),
|
|
203
169
|
filters: z
|
|
204
|
-
.record(
|
|
170
|
+
.record(
|
|
171
|
+
z.string(),
|
|
172
|
+
z.union([
|
|
173
|
+
z.coerce.string(),
|
|
174
|
+
z.record(z.string(), z.coerce.string()),
|
|
175
|
+
]),
|
|
176
|
+
)
|
|
205
177
|
.optional(),
|
|
206
178
|
}),
|
|
207
179
|
},
|
package/src/modules/auth.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import bearer from "@elysia/bearer";
|
|
2
|
-
import { makeModule } from "../core/module";
|
|
3
2
|
import Elysia, { status } from "elysia";
|
|
4
3
|
import moment from "moment";
|
|
5
4
|
import { eq, RecordId, Table, Uuid } from "surrealdb";
|
|
6
5
|
import z from "zod";
|
|
6
|
+
import { makeModule } from "../core/module";
|
|
7
7
|
import elysia from "./elysia";
|
|
8
8
|
import surreal from "./surreal";
|
|
9
9
|
|
|
@@ -30,19 +30,26 @@ const auth = makeModule(() => {
|
|
|
30
30
|
|
|
31
31
|
const [_table, tokenId, plainToken] = bearer.split(":");
|
|
32
32
|
|
|
33
|
-
const token = await surreal()
|
|
33
|
+
const token = await surreal()
|
|
34
|
+
.select<Token>(new RecordId(table, tokenId!))
|
|
35
|
+
.fetch("user");
|
|
34
36
|
|
|
35
37
|
if (!token) {
|
|
36
38
|
throw new Error("Unauthorized");
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
const hash = new Bun.CryptoHasher("sha256")
|
|
41
|
+
const hash = new Bun.CryptoHasher("sha256")
|
|
42
|
+
.update(plainToken!)
|
|
43
|
+
.digest("utf8");
|
|
40
44
|
|
|
41
45
|
if (hash !== token.hash) {
|
|
42
46
|
throw new Error("Unauthorized");
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
const { password: _, ...safeUser } =
|
|
49
|
+
const { password: _, ...safeUser } =
|
|
50
|
+
token.user as typeof token.user & {
|
|
51
|
+
password: string;
|
|
52
|
+
};
|
|
46
53
|
|
|
47
54
|
return {
|
|
48
55
|
user: { ...safeUser, tokenId: token.id },
|
|
@@ -79,7 +86,11 @@ const auth = makeModule(() => {
|
|
|
79
86
|
prefix: "/api/auth",
|
|
80
87
|
})
|
|
81
88
|
.use((await fn()).plugin)
|
|
82
|
-
.get(
|
|
89
|
+
.get(
|
|
90
|
+
"/check-token",
|
|
91
|
+
({ user: { tokenId: _, ...user } }) => ({ data: user }),
|
|
92
|
+
{ auth: true },
|
|
93
|
+
)
|
|
83
94
|
.delete(
|
|
84
95
|
"/logout",
|
|
85
96
|
async ({ user: { tokenId, ...user } }) => {
|
|
@@ -93,7 +104,9 @@ const auth = makeModule(() => {
|
|
|
93
104
|
"/login",
|
|
94
105
|
async ({ body }) => {
|
|
95
106
|
try {
|
|
96
|
-
const [data] = await surreal()
|
|
107
|
+
const [data] = await surreal()
|
|
108
|
+
.select<Token["user"]>(user.table)
|
|
109
|
+
.where(eq("email", body.email));
|
|
97
110
|
|
|
98
111
|
if (!data) {
|
|
99
112
|
throw new Error("Unauthorized");
|
|
@@ -108,7 +121,9 @@ const auth = makeModule(() => {
|
|
|
108
121
|
const [token] = await surreal()
|
|
109
122
|
.create<Token>(table)
|
|
110
123
|
.content({
|
|
111
|
-
hash: new Bun.CryptoHasher("sha256")
|
|
124
|
+
hash: new Bun.CryptoHasher("sha256")
|
|
125
|
+
.update(plainToken)
|
|
126
|
+
.digest("utf8"),
|
|
112
127
|
user: data.id as unknown as Token["user"],
|
|
113
128
|
createdAt: moment().toISOString(),
|
|
114
129
|
updatedAt: moment().toISOString(),
|
|
@@ -116,7 +131,9 @@ const auth = makeModule(() => {
|
|
|
116
131
|
|
|
117
132
|
await surreal().relate(token!.id, new Table("user_token"), data.id);
|
|
118
133
|
|
|
119
|
-
const { password: _, ...safeData } = data as typeof data & {
|
|
134
|
+
const { password: _, ...safeData } = data as typeof data & {
|
|
135
|
+
password: string;
|
|
136
|
+
};
|
|
120
137
|
|
|
121
138
|
return {
|
|
122
139
|
data: {
|
package/src/modules/elysia.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import cors from "@elysia/cors";
|
|
2
|
+
import Elysia, { type AnyElysia, status, ValidationError } from "elysia";
|
|
2
3
|
import { getConfig } from "../core/config";
|
|
3
4
|
import { UniqueConstraintError } from "../core/model";
|
|
4
5
|
import { makeModule } from "../core/module";
|
|
5
6
|
import { serveWorker } from "../core/worker";
|
|
6
|
-
import Elysia, { status, ValidationError, type AnyElysia } from "elysia";
|
|
7
7
|
import logger from "./logger";
|
|
8
8
|
|
|
9
9
|
// Elysia's query parser only understands object/record-shaped query schemas as a single
|
package/src/modules/file.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import Elysia from "elysia";
|
|
3
|
+
import { RecordId } from "surrealdb";
|
|
4
|
+
import z from "zod";
|
|
1
5
|
import { getConfig } from "../core/config";
|
|
2
6
|
import type { makeModel } from "../core/model";
|
|
3
7
|
import { makeModule } from "../core/module";
|
|
4
8
|
import { resolveSafePath } from "../core/path_guard";
|
|
5
|
-
import Elysia from "elysia";
|
|
6
|
-
import { resolve } from "node:path";
|
|
7
|
-
import { RecordId } from "surrealdb";
|
|
8
|
-
import z from "zod";
|
|
9
9
|
import auth from "./auth";
|
|
10
10
|
import elysia from "./elysia";
|
|
11
11
|
import surreal from "./surreal";
|
|
@@ -22,7 +22,9 @@ export type FileUpload<Meta> = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export interface FileAdapter<Meta> {
|
|
25
|
-
write: (
|
|
25
|
+
write: (
|
|
26
|
+
file: File,
|
|
27
|
+
) => Promise<Omit<FileUpload<Meta>, "id" | "createdAt" | "updatedAt">>;
|
|
26
28
|
unlink: (file: FileUpload<Meta>) => Promise<void>;
|
|
27
29
|
meta: Meta;
|
|
28
30
|
// Only set by filesystem-backed adapters — tells this module where to serve
|
|
@@ -33,14 +35,33 @@ export interface FileAdapter<Meta> {
|
|
|
33
35
|
|
|
34
36
|
const file = makeModule(() => {
|
|
35
37
|
let model: ReturnType<typeof makeModel>;
|
|
38
|
+
// Set in init() once the file model is loaded — shared by the REST routes below and any
|
|
39
|
+
// other caller (e.g. the MCP upload flow) so every path goes through the same adapter.
|
|
40
|
+
let upload: (file: File) => Promise<unknown>;
|
|
41
|
+
let remove: (id: string) => Promise<unknown>;
|
|
36
42
|
|
|
37
|
-
const fn = () => ({ model });
|
|
43
|
+
const fn = () => ({ model, upload, remove });
|
|
38
44
|
|
|
39
45
|
fn.init = async () => {
|
|
40
46
|
const { default: baseModel } = await import("../models/file");
|
|
41
47
|
|
|
42
48
|
type FileUploadCurrent = Awaited<ReturnType<typeof baseModel.create>>;
|
|
43
49
|
|
|
50
|
+
upload = async (file) =>
|
|
51
|
+
surreal()
|
|
52
|
+
.create<Omit<FileUploadCurrent, "id">>(baseModel.table)
|
|
53
|
+
.content(await getConfig().adapters.file.write(file));
|
|
54
|
+
|
|
55
|
+
remove = async (id) => {
|
|
56
|
+
const data = await surreal().delete<FileUploadCurrent>(
|
|
57
|
+
new RecordId(baseModel.table, id),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await getConfig().adapters.file.unlink(data);
|
|
61
|
+
|
|
62
|
+
return data;
|
|
63
|
+
};
|
|
64
|
+
|
|
44
65
|
const controller = new Elysia({ prefix: "/api/file" })
|
|
45
66
|
.use((await auth()).plugin)
|
|
46
67
|
.get(
|
|
@@ -62,11 +83,7 @@ const file = makeModule(() => {
|
|
|
62
83
|
.delete(
|
|
63
84
|
"/:id",
|
|
64
85
|
async ({ params }) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
await getConfig().adapters.file.unlink(data);
|
|
68
|
-
|
|
69
|
-
return { data };
|
|
86
|
+
return { data: await remove(params.id) };
|
|
70
87
|
},
|
|
71
88
|
{
|
|
72
89
|
params: z.object({
|
|
@@ -78,11 +95,7 @@ const file = makeModule(() => {
|
|
|
78
95
|
.post(
|
|
79
96
|
"/",
|
|
80
97
|
async ({ body }) => {
|
|
81
|
-
|
|
82
|
-
.create<Omit<FileUploadCurrent, "id">>(baseModel.table)
|
|
83
|
-
.content(await getConfig().adapters.file.write(body.file));
|
|
84
|
-
|
|
85
|
-
return { data };
|
|
98
|
+
return { data: await upload(body.file) };
|
|
86
99
|
},
|
|
87
100
|
{
|
|
88
101
|
auth: true,
|
|
@@ -99,23 +112,27 @@ const file = makeModule(() => {
|
|
|
99
112
|
// already-absolute dir must win outright rather than get nested under rootDir.
|
|
100
113
|
const uploadsDir = resolve(getConfig().rootDir, adapterDir);
|
|
101
114
|
|
|
102
|
-
const uploadsController = new Elysia().get(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
decoded
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
const uploadsController = new Elysia().get(
|
|
116
|
+
"/uploads/*",
|
|
117
|
+
async ({ params }) => {
|
|
118
|
+
// Elysia leaves wildcard params percent-encoded — decode before touching the filesystem.
|
|
119
|
+
let decoded: string;
|
|
120
|
+
try {
|
|
121
|
+
decoded = decodeURIComponent(params["*"]);
|
|
122
|
+
} catch {
|
|
123
|
+
return new Response("Not Found", { status: 404 });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const requested = resolveSafePath(uploadsDir, decoded);
|
|
127
|
+
if (!requested) return new Response("Not Found", { status: 404 });
|
|
128
|
+
|
|
129
|
+
const file = Bun.file(requested);
|
|
130
|
+
if (!(await file.exists()))
|
|
131
|
+
return new Response("Not Found", { status: 404 });
|
|
132
|
+
|
|
133
|
+
return file;
|
|
134
|
+
},
|
|
135
|
+
);
|
|
119
136
|
|
|
120
137
|
elysia().use(controller).use(uploadsController);
|
|
121
138
|
} else {
|
package/src/modules/img.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import Elysia from "elysia";
|
|
1
2
|
import { getConfig } from "../core/config";
|
|
2
3
|
import { makeModule } from "../core/module";
|
|
3
|
-
import Elysia from "elysia";
|
|
4
4
|
import elysia from "./elysia";
|
|
5
5
|
import view from "./view";
|
|
6
6
|
|
|
7
|
-
export function isAllowed(
|
|
7
|
+
export function isAllowed(
|
|
8
|
+
imageUrl: string,
|
|
9
|
+
allowedHosts: string[] = getConfig().img.hosts,
|
|
10
|
+
): boolean {
|
|
8
11
|
if (imageUrl.startsWith("/")) return true;
|
|
9
12
|
|
|
10
13
|
let parsed: URL;
|
|
@@ -21,7 +24,11 @@ export function isAllowed(imageUrl: string, allowedHosts: string[] = getConfig()
|
|
|
21
24
|
// header, or allowing loopback unconditionally, would turn this into an SSRF
|
|
22
25
|
// primitive (an attacker controls both the `url` query param and their own
|
|
23
26
|
// request's `Host` header), so neither is honored here.
|
|
24
|
-
if (
|
|
27
|
+
if (
|
|
28
|
+
process.env.NODE_ENV !== "production" &&
|
|
29
|
+
(imageHostname === "localhost" || imageHostname === "127.0.0.1")
|
|
30
|
+
)
|
|
31
|
+
return true;
|
|
25
32
|
|
|
26
33
|
// Every other host: require explicit whitelist
|
|
27
34
|
return allowedHosts.includes(imageHostname);
|
|
@@ -42,13 +49,16 @@ const img = makeModule(() => {
|
|
|
42
49
|
const fn = () => {};
|
|
43
50
|
|
|
44
51
|
fn.init = async () => {
|
|
45
|
-
view().global(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
view().global(
|
|
53
|
+
"imgOptimize",
|
|
54
|
+
(url: string, options?: { w?: number; h?: number; q?: number }) => {
|
|
55
|
+
const params = new URLSearchParams({ url });
|
|
56
|
+
if (options?.w != null) params.set("w", String(options.w));
|
|
57
|
+
if (options?.h != null) params.set("h", String(options.h));
|
|
58
|
+
if (options?.q != null) params.set("q", String(options.q));
|
|
59
|
+
return `/img?${params.toString()}`;
|
|
60
|
+
},
|
|
61
|
+
);
|
|
52
62
|
|
|
53
63
|
const controller = new Elysia();
|
|
54
64
|
|
|
@@ -95,7 +105,9 @@ const img = makeModule(() => {
|
|
|
95
105
|
// pathname+query, but a handler further down that inspects request.url (absolute-URL
|
|
96
106
|
// generation, host-based logic, ...) should see the same scheme/host this request
|
|
97
107
|
// actually arrived on, not a fake one that may not match across environments.
|
|
98
|
-
const resp = await elysia().handle(
|
|
108
|
+
const resp = await elysia().handle(
|
|
109
|
+
new Request(`${new URL(request.url).origin}${url}`),
|
|
110
|
+
);
|
|
99
111
|
if (!resp.ok) return new Response("Image not found", { status: 404 });
|
|
100
112
|
imageData = await resp.arrayBuffer();
|
|
101
113
|
} else {
|
|
@@ -107,7 +119,8 @@ const img = makeModule(() => {
|
|
|
107
119
|
} catch {
|
|
108
120
|
return new Response("Failed to fetch image", { status: 502 });
|
|
109
121
|
}
|
|
110
|
-
if (!resp.ok)
|
|
122
|
+
if (!resp.ok)
|
|
123
|
+
return new Response("Failed to fetch image", { status: 502 });
|
|
111
124
|
imageData = await resp.arrayBuffer();
|
|
112
125
|
}
|
|
113
126
|
|
|
@@ -117,13 +130,19 @@ const img = makeModule(() => {
|
|
|
117
130
|
// Bun.Image.resize() takes width first and derives height from aspect ratio
|
|
118
131
|
// when height is omitted — there's no height-only equivalent, so width-only
|
|
119
132
|
// and width+height both go through this branch unchanged.
|
|
120
|
-
pipeline.resize(width, height, {
|
|
133
|
+
pipeline.resize(width, height, {
|
|
134
|
+
fit: "inside",
|
|
135
|
+
withoutEnlargement: true,
|
|
136
|
+
});
|
|
121
137
|
} else if (height) {
|
|
122
138
|
// Height-only request — derive a proportional width from the source dimensions
|
|
123
139
|
// since resize() requires a width argument.
|
|
124
140
|
const meta = await pipeline.metadata();
|
|
125
141
|
const derivedWidth = Math.round((meta.width / meta.height) * height);
|
|
126
|
-
pipeline.resize(derivedWidth, height, {
|
|
142
|
+
pipeline.resize(derivedWidth, height, {
|
|
143
|
+
fit: "inside",
|
|
144
|
+
withoutEnlargement: true,
|
|
145
|
+
});
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
const output = await pipeline.webp({ quality }).buffer();
|