@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,160 @@
|
|
|
1
|
+
import { getConfig } from "../core/config";
|
|
2
|
+
import { makeModule } from "../core/module";
|
|
3
|
+
import Elysia from "elysia";
|
|
4
|
+
import z from "zod";
|
|
5
|
+
import auth from "./auth";
|
|
6
|
+
import elysia from "./elysia";
|
|
7
|
+
|
|
8
|
+
const api = makeModule(() => {
|
|
9
|
+
const fn = () => {};
|
|
10
|
+
|
|
11
|
+
fn.init = async () => {
|
|
12
|
+
const models = getConfig().models;
|
|
13
|
+
const authPlugin = await auth();
|
|
14
|
+
|
|
15
|
+
const controller = new Elysia({ prefix: "/api/schema" }).use(authPlugin.plugin).get(
|
|
16
|
+
"/",
|
|
17
|
+
async () => {
|
|
18
|
+
const allModels = await Promise.all(
|
|
19
|
+
models.map((model) =>
|
|
20
|
+
(async () => {
|
|
21
|
+
const { default: data } = await model();
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
name: data.name,
|
|
25
|
+
prefix: data.prefix,
|
|
26
|
+
schema: data.validation.toJSONSchema({
|
|
27
|
+
unrepresentable: "any",
|
|
28
|
+
override: (ctx) => {
|
|
29
|
+
if (ctx.jsonSchema.renderAs === "relation") {
|
|
30
|
+
ctx.jsonSchema.type = "object";
|
|
31
|
+
ctx.jsonSchema.properties = {
|
|
32
|
+
id: {
|
|
33
|
+
type: "string",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
ctx.jsonSchema.required = ["id"];
|
|
37
|
+
ctx.jsonSchema.additionalProperties = {};
|
|
38
|
+
} else if (ctx.jsonSchema.renderAs === "multi_relation") {
|
|
39
|
+
(ctx.jsonSchema.items! as any).type = "string";
|
|
40
|
+
} else if (ctx.jsonSchema.renderAs === "file") {
|
|
41
|
+
ctx.jsonSchema.type = "string";
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
};
|
|
46
|
+
})(),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return { data: allModels };
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
auth: true,
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
elysia().use(controller);
|
|
58
|
+
|
|
59
|
+
for (const promise of models) {
|
|
60
|
+
const { default: model } = await promise();
|
|
61
|
+
|
|
62
|
+
const controller = new Elysia({ prefix: `/api/${model.prefix}` })
|
|
63
|
+
.use(authPlugin.plugin)
|
|
64
|
+
.delete(
|
|
65
|
+
"/:id",
|
|
66
|
+
async ({ params }) => {
|
|
67
|
+
const data = await model.destroy(params.id);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
data,
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
params: z.object({
|
|
75
|
+
id: z.coerce.string(),
|
|
76
|
+
}),
|
|
77
|
+
auth: true,
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
.get(
|
|
81
|
+
"/:id",
|
|
82
|
+
async ({ params }) => {
|
|
83
|
+
const data = await model.show(params.id);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
data,
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
params: z.object({
|
|
91
|
+
id: z.coerce.string(),
|
|
92
|
+
}),
|
|
93
|
+
auth: true,
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
.put(
|
|
97
|
+
"/:id",
|
|
98
|
+
async ({ params, body }) => {
|
|
99
|
+
const data = await model.update(params.id, body);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
data,
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
params: z.object({
|
|
107
|
+
id: z.coerce.string(),
|
|
108
|
+
}),
|
|
109
|
+
auth: true,
|
|
110
|
+
body: model.validation.partial(),
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
.post(
|
|
114
|
+
"/",
|
|
115
|
+
async ({ body }) => {
|
|
116
|
+
const data = await model.create(body);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
data,
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
auth: true,
|
|
124
|
+
body: model.validation,
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
.get(
|
|
128
|
+
"/",
|
|
129
|
+
async ({ query: { search, page, limit, sort, order, populate, filters } }) => {
|
|
130
|
+
const populateOpt =
|
|
131
|
+
populate === "none" ? ("none" as const) : populate ? populate.split(",").map((s) => s.trim()) : undefined;
|
|
132
|
+
|
|
133
|
+
return model.read({ search, page, limit, sort, order, populate: populateOpt, filters });
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
auth: true,
|
|
137
|
+
query: z.object({
|
|
138
|
+
search: z.coerce.string().optional(),
|
|
139
|
+
page: z.coerce.number().int().positive().default(1),
|
|
140
|
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
141
|
+
sort: z.coerce.string().optional(),
|
|
142
|
+
order: z.enum(["asc", "desc"]).default("asc"),
|
|
143
|
+
populate: z.coerce.string().optional(),
|
|
144
|
+
filters: z
|
|
145
|
+
.record(z.string(), z.union([z.coerce.string(), z.record(z.string(), z.coerce.string())]))
|
|
146
|
+
.optional(),
|
|
147
|
+
}),
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
elysia().use(controller);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {};
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return fn;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export default api;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import bearer from "@elysia/bearer";
|
|
2
|
+
import { makeModule } from "../core/module";
|
|
3
|
+
import Elysia, { status } from "elysia";
|
|
4
|
+
import moment from "moment";
|
|
5
|
+
import { eq, RecordId, Table, Uuid } from "surrealdb";
|
|
6
|
+
import z from "zod";
|
|
7
|
+
import elysia from "./elysia";
|
|
8
|
+
import surreal from "./surreal";
|
|
9
|
+
|
|
10
|
+
const table = new Table("token");
|
|
11
|
+
|
|
12
|
+
const auth = makeModule(() => {
|
|
13
|
+
const fn = async () => {
|
|
14
|
+
const { default: user } = await import("../models/user");
|
|
15
|
+
|
|
16
|
+
type Token = {
|
|
17
|
+
hash: string;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
updatedAt: string;
|
|
20
|
+
user: Awaited<ReturnType<typeof user.create>>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const plugin = new Elysia({ name: "auth" }).use(bearer()).macro({
|
|
24
|
+
auth: {
|
|
25
|
+
resolve: async ({ bearer }) => {
|
|
26
|
+
try {
|
|
27
|
+
if (!bearer) {
|
|
28
|
+
throw new Error("Unauthorized");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const [_table, tokenId, plainToken] = bearer.split(":");
|
|
32
|
+
|
|
33
|
+
const token = await surreal().select<Token>(new RecordId(table, tokenId!)).fetch("user");
|
|
34
|
+
|
|
35
|
+
if (!token) {
|
|
36
|
+
throw new Error("Unauthorized");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const hash = new Bun.CryptoHasher("sha256").update(plainToken!).digest("utf8");
|
|
40
|
+
|
|
41
|
+
if (hash !== token.hash) {
|
|
42
|
+
throw new Error("Unauthorized");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { password: _, ...safeUser } = token.user as typeof token.user & { password: string };
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
user: { ...safeUser, tokenId: token.id },
|
|
49
|
+
};
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e instanceof Error && e.message === "Unauthorized") {
|
|
52
|
+
return status(401, {
|
|
53
|
+
message: e.message,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
throw e;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
plugin,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
fn.init = async () => {
|
|
69
|
+
const { default: user } = await import("../models/user");
|
|
70
|
+
|
|
71
|
+
type Token = {
|
|
72
|
+
hash: string;
|
|
73
|
+
createdAt: string;
|
|
74
|
+
updatedAt: string;
|
|
75
|
+
user: Awaited<ReturnType<typeof user.create>>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const controller = new Elysia({
|
|
79
|
+
prefix: "/api/auth",
|
|
80
|
+
})
|
|
81
|
+
.use((await fn()).plugin)
|
|
82
|
+
.get("/check-token", ({ user: { tokenId: _, ...user } }) => ({ data: user }), { auth: true })
|
|
83
|
+
.delete(
|
|
84
|
+
"/logout",
|
|
85
|
+
async ({ user: { tokenId, ...user } }) => {
|
|
86
|
+
await surreal().delete(tokenId);
|
|
87
|
+
|
|
88
|
+
return { data: user };
|
|
89
|
+
},
|
|
90
|
+
{ auth: true },
|
|
91
|
+
)
|
|
92
|
+
.post(
|
|
93
|
+
"/login",
|
|
94
|
+
async ({ body }) => {
|
|
95
|
+
try {
|
|
96
|
+
const [data] = await surreal().select<Token["user"]>(user.table).where(eq("email", body.email));
|
|
97
|
+
|
|
98
|
+
if (!data) {
|
|
99
|
+
throw new Error("Unauthorized");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!(await Bun.password.verify(body.password, data.password))) {
|
|
103
|
+
throw new Error("Unauthorized");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const plainToken = Uuid.v7().toString();
|
|
107
|
+
|
|
108
|
+
const [token] = await surreal()
|
|
109
|
+
.create<Token>(table)
|
|
110
|
+
.content({
|
|
111
|
+
hash: new Bun.CryptoHasher("sha256").update(plainToken).digest("utf8"),
|
|
112
|
+
user: data.id as unknown as Token["user"],
|
|
113
|
+
createdAt: moment().toISOString(),
|
|
114
|
+
updatedAt: moment().toISOString(),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await surreal().relate(token!.id, new Table("user_token"), data.id);
|
|
118
|
+
|
|
119
|
+
const { password: _, ...safeData } = data as typeof data & { password: string };
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
data: {
|
|
123
|
+
...safeData,
|
|
124
|
+
token: `${table.toString()}:${token?.id.id}:${plainToken}`,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
} catch (e) {
|
|
128
|
+
if (e instanceof Error && e.message === "Unauthorized") {
|
|
129
|
+
return status(401, {
|
|
130
|
+
message: "Unauthorized",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
body: z.object({
|
|
139
|
+
email: z.email(),
|
|
140
|
+
password: z.string(),
|
|
141
|
+
}),
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
elysia().use(controller);
|
|
146
|
+
|
|
147
|
+
return {};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return fn;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
export default auth;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import cors from "@elysia/cors";
|
|
2
|
+
import { getConfig } from "../core/config";
|
|
3
|
+
import { UniqueConstraintError } from "../core/model";
|
|
4
|
+
import { makeModule } from "../core/module";
|
|
5
|
+
import Elysia, { status, ValidationError, type AnyElysia } from "elysia";
|
|
6
|
+
import logger from "./logger";
|
|
7
|
+
|
|
8
|
+
// Elysia's query parser only understands object/record-shaped query schemas as a single
|
|
9
|
+
// JSON-encoded string — not the conventional bracket notation (`field[key]=value`,
|
|
10
|
+
// `field[key][op]=value`) that Axios/qs/jQuery produce by default. Parsed here, globally,
|
|
11
|
+
// before validation runs, so every route's Zod query schema "just works" with that notation
|
|
12
|
+
// without any per-route setup.
|
|
13
|
+
const BRACKET_KEY_RE = /^([^[\]]+)\[([^\]]+)\](?:\[([^\]]+)\])?$/;
|
|
14
|
+
|
|
15
|
+
export const injectBracketQuery = ({ query, request }: { query: Record<string, unknown>; request: Request }) => {
|
|
16
|
+
const params = new URL(request.url).searchParams;
|
|
17
|
+
|
|
18
|
+
for (const [key, value] of params) {
|
|
19
|
+
const match = BRACKET_KEY_RE.exec(key);
|
|
20
|
+
if (!match) continue;
|
|
21
|
+
|
|
22
|
+
const [, root, sub1, sub2] = match as unknown as [string, string, string, string | undefined];
|
|
23
|
+
const existingRoot = query[root];
|
|
24
|
+
const rootObj = typeof existingRoot === "object" && existingRoot !== null ? (existingRoot as Record<string, unknown>) : {};
|
|
25
|
+
|
|
26
|
+
if (sub2) {
|
|
27
|
+
const existingSub = rootObj[sub1];
|
|
28
|
+
rootObj[sub1] = {
|
|
29
|
+
...(typeof existingSub === "object" && existingSub !== null ? (existingSub as Record<string, unknown>) : {}),
|
|
30
|
+
[sub2]: value,
|
|
31
|
+
};
|
|
32
|
+
} else {
|
|
33
|
+
rootObj[sub1] = value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
query[root] = rootObj;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const elysia = makeModule(() => {
|
|
41
|
+
let app: AnyElysia;
|
|
42
|
+
|
|
43
|
+
const fn = () => {
|
|
44
|
+
return app;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
fn.init = () => {
|
|
48
|
+
app = new Elysia()
|
|
49
|
+
.use(cors())
|
|
50
|
+
.onTransform({ as: "global" }, injectBracketQuery)
|
|
51
|
+
.onError(({ error }) => {
|
|
52
|
+
if (error instanceof ValidationError) {
|
|
53
|
+
return status(422, {
|
|
54
|
+
message: "Validation error",
|
|
55
|
+
errors: (error.detail("") as { errors: { value: {}; summary: string }[] }).errors.map(({ value, summary, ...err }) => err),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (error instanceof UniqueConstraintError) {
|
|
60
|
+
return status(409, {
|
|
61
|
+
message: error.message,
|
|
62
|
+
field: error.field,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
logger().error(error);
|
|
67
|
+
|
|
68
|
+
return status(500, {
|
|
69
|
+
message: "Internal Server Error",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
// No startup log here — the CLI prints one consolidated banner after every
|
|
75
|
+
// module has finished booting (src/core/banner.ts), instead of one line per module.
|
|
76
|
+
spin: () => {
|
|
77
|
+
app.listen(getConfig().port);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return fn;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
export default elysia;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { getConfig } from "../core/config";
|
|
2
|
+
import type { makeModel } from "../core/model";
|
|
3
|
+
import { makeModule } from "../core/module";
|
|
4
|
+
import { resolveSafePath } from "../core/path_guard";
|
|
5
|
+
import Elysia from "elysia";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { RecordId } from "surrealdb";
|
|
8
|
+
import z from "zod";
|
|
9
|
+
import auth from "./auth";
|
|
10
|
+
import elysia from "./elysia";
|
|
11
|
+
import surreal from "./surreal";
|
|
12
|
+
|
|
13
|
+
export type FileUpload<Meta> = {
|
|
14
|
+
id: RecordId;
|
|
15
|
+
name: string;
|
|
16
|
+
size: number;
|
|
17
|
+
type: string;
|
|
18
|
+
meta: z.infer<Meta>;
|
|
19
|
+
url: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface FileAdapter<Meta> {
|
|
25
|
+
write: (file: File) => Promise<Omit<FileUpload<Meta>, "id" | "createdAt" | "updatedAt">>;
|
|
26
|
+
unlink: (file: FileUpload<Meta>) => Promise<void>;
|
|
27
|
+
meta: Meta;
|
|
28
|
+
// Only set by filesystem-backed adapters — tells this module where to serve
|
|
29
|
+
// /uploads/* from. Adapters like S3 serve files from their own public URL instead,
|
|
30
|
+
// so they omit this and the static route below never gets mounted.
|
|
31
|
+
dir?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const file = makeModule(() => {
|
|
35
|
+
let model: ReturnType<typeof makeModel>;
|
|
36
|
+
|
|
37
|
+
const fn = () => ({ model });
|
|
38
|
+
|
|
39
|
+
fn.init = async () => {
|
|
40
|
+
const { default: baseModel } = await import("../models/file");
|
|
41
|
+
|
|
42
|
+
type FileUploadCurrent = Awaited<ReturnType<typeof baseModel.create>>;
|
|
43
|
+
|
|
44
|
+
const controller = new Elysia({ prefix: "/api/file" })
|
|
45
|
+
.use((await auth()).plugin)
|
|
46
|
+
.get(
|
|
47
|
+
"/",
|
|
48
|
+
async ({ query: { search, page, limit, sort, order } }) => {
|
|
49
|
+
return baseModel.read({ search, page, limit, sort, order });
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
auth: true,
|
|
53
|
+
query: z.object({
|
|
54
|
+
search: z.coerce.string().optional(),
|
|
55
|
+
page: z.coerce.number().int().positive().default(1),
|
|
56
|
+
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
57
|
+
sort: z.coerce.string().optional(),
|
|
58
|
+
order: z.enum(["asc", "desc"]).default("asc"),
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
.delete(
|
|
63
|
+
"/:id",
|
|
64
|
+
async ({ params }) => {
|
|
65
|
+
const data = await surreal().delete<FileUploadCurrent>(new RecordId(baseModel.table, params.id));
|
|
66
|
+
|
|
67
|
+
await getConfig().adapters.file.unlink(data);
|
|
68
|
+
|
|
69
|
+
return { data };
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
params: z.object({
|
|
73
|
+
id: z.coerce.string(),
|
|
74
|
+
}),
|
|
75
|
+
auth: true,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
.post(
|
|
79
|
+
"/",
|
|
80
|
+
async ({ body }) => {
|
|
81
|
+
const data = await surreal()
|
|
82
|
+
.create<Omit<FileUploadCurrent, "id">>(baseModel.table)
|
|
83
|
+
.content(await getConfig().adapters.file.write(body.file));
|
|
84
|
+
|
|
85
|
+
return { data };
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
auth: true,
|
|
89
|
+
body: z.object({
|
|
90
|
+
file: z.file(),
|
|
91
|
+
}),
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const adapterDir = getConfig().adapters.file.dir;
|
|
96
|
+
|
|
97
|
+
if (adapterDir) {
|
|
98
|
+
const uploadsDir = join(process.cwd(), adapterDir);
|
|
99
|
+
|
|
100
|
+
const uploadsController = new Elysia().get("/uploads/*", async ({ params }) => {
|
|
101
|
+
// Elysia leaves wildcard params percent-encoded — decode before touching the filesystem.
|
|
102
|
+
let decoded: string;
|
|
103
|
+
try {
|
|
104
|
+
decoded = decodeURIComponent(params["*"]);
|
|
105
|
+
} catch {
|
|
106
|
+
return new Response("Not Found", { status: 404 });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const requested = resolveSafePath(uploadsDir, decoded);
|
|
110
|
+
if (!requested) return new Response("Not Found", { status: 404 });
|
|
111
|
+
|
|
112
|
+
const file = Bun.file(requested);
|
|
113
|
+
if (!(await file.exists())) return new Response("Not Found", { status: 404 });
|
|
114
|
+
|
|
115
|
+
return file;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
elysia().use(controller).use(uploadsController);
|
|
119
|
+
} else {
|
|
120
|
+
elysia().use(controller);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
model = baseModel;
|
|
124
|
+
|
|
125
|
+
return {};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return fn;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export default file;
|
|
@@ -0,0 +1,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, requestHost: 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
|
+
// Strip port — host header may include it (e.g. "localhost:3000")
|
|
19
|
+
const requestHostname = requestHost.split(":")[0];
|
|
20
|
+
|
|
21
|
+
// Same host as the incoming request — works across dev, staging, and production
|
|
22
|
+
if (imageHostname === requestHostname) return true;
|
|
23
|
+
|
|
24
|
+
// localhost variants — covers Vite dev server running on a different port (5173)
|
|
25
|
+
if (imageHostname === "localhost" || imageHostname === "127.0.0.1") return true;
|
|
26
|
+
|
|
27
|
+
// Truly external hosts: require explicit whitelist
|
|
28
|
+
return allowedHosts.includes(imageHostname);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractFilename(url: string): string {
|
|
32
|
+
try {
|
|
33
|
+
const pathname = url.startsWith("/") ? url : new URL(url).pathname;
|
|
34
|
+
const last = pathname.split("/").filter(Boolean).pop() ?? "image";
|
|
35
|
+
const name = last.replace(/\.[^.]+$/, "") || "image";
|
|
36
|
+
return `${name}.webp`;
|
|
37
|
+
} catch {
|
|
38
|
+
return "image.webp";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const img = makeModule(() => {
|
|
43
|
+
const fn = () => {};
|
|
44
|
+
|
|
45
|
+
fn.init = async () => {
|
|
46
|
+
view().global("imgOptimize", (url: string, options?: { w?: number; h?: number; q?: number }) => {
|
|
47
|
+
const params = new URLSearchParams({ url });
|
|
48
|
+
if (options?.w != null) params.set("w", String(options.w));
|
|
49
|
+
if (options?.h != null) params.set("h", String(options.h));
|
|
50
|
+
if (options?.q != null) params.set("q", String(options.q));
|
|
51
|
+
return `/img?${params.toString()}`;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const controller = new Elysia();
|
|
55
|
+
|
|
56
|
+
controller.get("/img", async ({ query, request }) => {
|
|
57
|
+
const { url, w, h, q = "80" } = query;
|
|
58
|
+
|
|
59
|
+
if (!url) {
|
|
60
|
+
return new Response("Missing url parameter", { status: 400 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const width = w ? parseInt(w) : undefined;
|
|
64
|
+
const height = h ? parseInt(h) : undefined;
|
|
65
|
+
const quality = Math.min(100, Math.max(1, parseInt(q)));
|
|
66
|
+
|
|
67
|
+
if (isNaN(quality)) {
|
|
68
|
+
return new Response("Invalid q parameter", { status: 400 });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const requestHost = request.headers.get("host") ?? "";
|
|
72
|
+
|
|
73
|
+
if (!isAllowed(url, requestHost)) {
|
|
74
|
+
return new Response("Host not allowed", { status: 403 });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ETag is deterministic from input params — allows 304 without fetching or processing
|
|
78
|
+
const etag = `"${Bun.hash(`${url}|${w ?? ""}|${h ?? ""}|${quality}`).toString(16)}"`;
|
|
79
|
+
const cacheControl = "public, max-age=31536000, immutable";
|
|
80
|
+
|
|
81
|
+
if (request.headers.get("if-none-match") === etag) {
|
|
82
|
+
return new Response(null, {
|
|
83
|
+
status: 304,
|
|
84
|
+
headers: { ETag: etag, "Cache-Control": cacheControl },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let imageData: ArrayBuffer;
|
|
89
|
+
|
|
90
|
+
if (url.startsWith("/")) {
|
|
91
|
+
const resp = await fetch(`http://localhost:${getConfig().port}${url}`);
|
|
92
|
+
if (!resp.ok) return new Response("Image not found", { status: 404 });
|
|
93
|
+
imageData = await resp.arrayBuffer();
|
|
94
|
+
} else {
|
|
95
|
+
const resp = await fetch(url);
|
|
96
|
+
if (!resp.ok) return new Response("Failed to fetch image", { status: 502 });
|
|
97
|
+
imageData = await resp.arrayBuffer();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const pipeline = new Bun.Image(imageData);
|
|
101
|
+
|
|
102
|
+
if (width) {
|
|
103
|
+
// Bun.Image.resize() takes width first and derives height from aspect ratio
|
|
104
|
+
// when height is omitted — there's no height-only equivalent, so width-only
|
|
105
|
+
// and width+height both go through this branch unchanged.
|
|
106
|
+
pipeline.resize(width, height, { fit: "inside", withoutEnlargement: true });
|
|
107
|
+
} else if (height) {
|
|
108
|
+
// Height-only request — derive a proportional width from the source dimensions
|
|
109
|
+
// since resize() requires a width argument.
|
|
110
|
+
const meta = await pipeline.metadata();
|
|
111
|
+
const derivedWidth = Math.round((meta.width / meta.height) * height);
|
|
112
|
+
pipeline.resize(derivedWidth, height, { fit: "inside", withoutEnlargement: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const output = await pipeline.webp({ quality }).buffer();
|
|
116
|
+
const filename = extractFilename(url);
|
|
117
|
+
|
|
118
|
+
return new Response(new Uint8Array(output), {
|
|
119
|
+
headers: {
|
|
120
|
+
"Content-Type": "image/webp",
|
|
121
|
+
"Content-Length": String(output.byteLength),
|
|
122
|
+
"Content-Disposition": `inline; filename="${filename}"`,
|
|
123
|
+
"Cache-Control": cacheControl,
|
|
124
|
+
ETag: etag,
|
|
125
|
+
Vary: "Accept",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
elysia().use(controller);
|
|
131
|
+
return {};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return fn;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export default img;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { makeModule } from "../core/module";
|
|
2
|
+
import pino from "pino";
|
|
3
|
+
import pretty from "pino-pretty";
|
|
4
|
+
|
|
5
|
+
const logger = makeModule(() => {
|
|
6
|
+
let p: pino.Logger;
|
|
7
|
+
|
|
8
|
+
const fn = () => p;
|
|
9
|
+
|
|
10
|
+
fn.init = () => {
|
|
11
|
+
p = pino({}, pretty({ colorize: true }));
|
|
12
|
+
return {};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
return fn;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default logger;
|